Moderate severityOSV Advisory· Published Dec 10, 2025· Updated Dec 10, 2025
CVE-2025-67638
CVE-2025-67638
Description
Jenkins 2.540 and earlier, LTS 2.528.2 and earlier does not mask build authorization tokens displayed on the job configuration form, increasing the potential for attackers to observe and capture them.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
org.jenkins-ci.main:jenkins-coreMaven | >= 2.529, < 2.541 | 2.541 |
org.jenkins-ci.main:jenkins-coreMaven | < 2.528.3 | 2.528.3 |
Affected products
1Patches
16 files changed · +292 −5
core/src/main/java/hudson/model/BuildAuthorizationToken.java+71 −4 modified@@ -25,10 +25,22 @@ package hudson.model; import com.thoughtworks.xstream.converters.basic.AbstractSingleValueConverter; +import hudson.Extension; import hudson.Util; +import hudson.diagnosis.OldDataMonitor; +import hudson.model.listeners.ItemListener; import hudson.security.ACL; +import hudson.util.Secret; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.logging.Level; +import java.util.logging.Logger; +import jenkins.model.Jenkins; +import jenkins.model.ParameterizedJobMixIn; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; import org.kohsuke.stapler.HttpResponses; import org.kohsuke.stapler.StaplerRequest; import org.kohsuke.stapler.StaplerRequest2; @@ -47,10 +59,25 @@ */ @Deprecated public final class BuildAuthorizationToken { - private final String token; + private final Secret token; + private transient boolean fromPlaintext; + + /** + * @deprecated since TODO + */ + @Deprecated public BuildAuthorizationToken(String token) { + this.token = Secret.fromString(token); + this.fromPlaintext = true; + } + + /** + * @since TODO + */ + public BuildAuthorizationToken(Secret token) { this.token = token; + this.fromPlaintext = false; } /** @@ -85,7 +112,7 @@ public static void checkPermission(Job<?, ?> project, BuildAuthorizationToken to if (token != null && token.token != null) { //check the provided token String providedToken = req.getParameter("token"); - if (providedToken != null && providedToken.equals(token.token)) + if (providedToken != null && MessageDigest.isEqual(providedToken.getBytes(StandardCharsets.UTF_8), token.getToken().getBytes(StandardCharsets.UTF_8))) return; if (providedToken != null) throw new AccessDeniedException(Messages.BuildAuthorizationToken_InvalidTokenProvided()); @@ -110,7 +137,15 @@ public static void checkPermission(Job<?, ?> project, BuildAuthorizationToken to checkPermission(project, token, StaplerRequest.toStaplerRequest2(req), StaplerResponse.toStaplerResponse2(rsp)); } + @Deprecated public String getToken() { + return token.getPlainText(); + } + + /** + * @since TODO + */ + public Secret getEncryptedToken() { return token; } @@ -122,12 +157,44 @@ public boolean canConvert(Class type) { @Override public Object fromString(String str) { - return new BuildAuthorizationToken(str); + if (Secret.decrypt(str) == null) { + return new BuildAuthorizationToken(str); + } + return new BuildAuthorizationToken(Secret.fromString(str)); } @Override public String toString(Object obj) { - return ((BuildAuthorizationToken) obj).token; + final BuildAuthorizationToken bat = (BuildAuthorizationToken) obj; + // We assume this only gets called when re-saving to its usual destination, so let's clear the in-memory state: + bat.fromPlaintext = false; + return bat.token.getEncryptedValue(); + } + } + + @Extension + @Restricted(NoExternalUse.class) + public static class ItemListenerImpl extends ItemListener { + private static final Logger LOGGER = Logger.getLogger(ItemListenerImpl.class.getName()); + + @Override + public void onUpdated(Item item) { + if (item instanceof ParameterizedJobMixIn.ParameterizedJob job) { + BuildAuthorizationToken bat = job.getAuthToken(); + if (bat != null) { + if (bat.fromPlaintext) { + OldDataMonitor.report(item, "2.528.3 / 2.541"); + LOGGER.log(Level.FINE, "Reporting " + item.getFullName()); + } else { + LOGGER.log(Level.FINE, "Skipping reporting of " + item.getFullName()); + } + } + } + } + + @Override + public void onLoaded() { + Jenkins.get().getAllItems(ParameterizedJobMixIn.ParameterizedJob.class).forEach(this::onUpdated); } } }
core/src/main/resources/hudson/model/BuildAuthorizationToken/config.jelly+2 −1 modified@@ -35,7 +35,8 @@ THE SOFTWARE. title="${%Trigger builds remotely} (${%e.g., from scripts})" checked="${it.authToken!=null}"> <f:entry title="${%Authentication Token}"> - <f:textbox name="authToken" value="${it.authToken.token}" /> + <f:password name="authToken" value="${it.authToken.encryptedToken}" /> + <div class="clearfix"/><!-- f:password in readOnlyMode only has a span, not a div, so the following text would be on the same line as "****" --> ${%Use the following URL to trigger build remotely:} <code>JENKINS_URL</code>/${it.url}build?token=<code>TOKEN_NAME</code> ${%or}
test/src/test/java/hudson/model/BuildAuthorizationTokenMigrationTest.java+166 −0 added@@ -0,0 +1,166 @@ +package hudson.model; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.notNullValue; + +import hudson.util.Secret; +import jakarta.servlet.ServletRequest; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import jenkins.model.Jenkins; +import org.apache.commons.io.IOUtils; +import org.htmlunit.HttpMethod; +import org.htmlunit.Page; +import org.htmlunit.WebRequest; +import org.htmlunit.html.HtmlForm; +import org.htmlunit.html.HtmlPage; +import org.htmlunit.xml.XmlPage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.MockAuthorizationStrategy; +import org.jvnet.hudson.test.junit.jupiter.WithJenkins; +import org.jvnet.hudson.test.recipes.LocalData; + +@SuppressWarnings("deprecation") +@Issue("SECURITY-783") +@WithJenkins +public class BuildAuthorizationTokenMigrationTest { + private static final String OLDTOKEN = "oldtoken"; + private static final String ADMIN_USERNAME = "alice"; + private static final String VERSION_NUMBER = "2.528.3 / 2.541"; + private static final String JOB_NAME = "my_freestyle_job"; + private static final String OLD_DATA_PAGE_URL = "administrativeMonitor/OldData/manage"; + private static final String JOB_WITHOUT_TOKEN_NAME = "job_without_token"; + public static final String EXTENDED_READER_USERNAME = "bob"; + + // This should be a JenkinsSessionRule / RealJenkinsRule, to ensure no monitor after startup, + // but it looks like neither works (anymore) with @LocalData/@WithLocalData. + private JenkinsRule j; + + @BeforeEach + public void setUp(JenkinsRule j) { + this.j = j; + } + + @Test + @LocalData + void basicMigration() throws Throwable { + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + j.jenkins.setAuthorizationStrategy(new MockAuthorizationStrategy() + .grant(Jenkins.READ).everywhere().toEveryone() + .grant(Item.READ, Item.EXTENDED_READ).everywhere().to(EXTENDED_READER_USERNAME) + .grant(Jenkins.ADMINISTER).everywhere().to(ADMIN_USERNAME)); + j.jenkins.save(); + + final FreeStyleProject fs = j.jenkins.getItemByFullName(JOB_NAME, FreeStyleProject.class); + + { + // inspect original migrated token + assertThat(fs.getConfigFile().asString(), containsString("<authToken>" + OLDTOKEN + "</authToken>")); + + final BuildAuthorizationToken authToken = fs.getAuthToken(); + assertThat(authToken, notNullValue()); + assertThat(authToken.getToken(), equalTo(OLDTOKEN)); + assertThat(authToken.getEncryptedToken().getPlainText(), equalTo(OLDTOKEN)); + + // Not redacted yet + try (JenkinsRule.WebClient wc = j.createWebClient().login(EXTENDED_READER_USERNAME)) { + final XmlPage xmlPage = wc.goToXml(fs.getUrl() + "config.xml"); + assertThat(xmlPage.getWebResponse().getContentAsString(), containsString("<authToken>" + OLDTOKEN + "</authToken>")); + } + } + + assertThat(j.jenkins.getAdministrativeMonitor("OldData").isActivated(), equalTo(true)); + + try (JenkinsRule.WebClient wc = j.createWebClient().login(ADMIN_USERNAME)) { + { + final HtmlPage oldDataPage = wc.goTo(OLD_DATA_PAGE_URL); + final String oldDataContent = oldDataPage.getWebResponse().getContentAsString(); + assertThat(oldDataContent, containsString(JOB_NAME)); + assertThat(oldDataContent, not(containsString(JOB_WITHOUT_TOKEN_NAME))); + + assertThat(oldDataContent, containsString(VERSION_NUMBER)); + assertThat(oldDataContent, not(containsString("No old data"))); + + final HtmlForm form = oldDataPage.getFormByName("oldDataUpgrade"); + form.submit(form.getButtonByName("Submit")); + } + { + final HtmlPage oldDataPage = wc.goTo(OLD_DATA_PAGE_URL); + final String oldDataContent = oldDataPage.getWebResponse().getContentAsString(); + assertThat(oldDataContent, not(containsString(JOB_NAME))); + assertThat(oldDataContent, not(containsString(VERSION_NUMBER))); + assertThat(oldDataContent, containsString("No old data")); + } + } + + assertThat(j.jenkins.getAdministrativeMonitor("OldData").isActivated(), equalTo(false)); + + { + // New auth token + final BuildAuthorizationToken authToken = fs.getAuthToken(); + assertThat(authToken, notNullValue()); + assertThat(authToken.getToken(), equalTo(OLDTOKEN)); + final Secret newEncryptedToken = authToken.getEncryptedToken(); + assertThat(newEncryptedToken.getPlainText(), equalTo(OLDTOKEN)); + + assertThat(fs.getConfigFile().asString(), not(containsString("<authToken>" + OLDTOKEN + "</authToken>"))); + assertThat(fs.getConfigFile().asString(), containsString("<authToken>" + newEncryptedToken.getEncryptedValue() + "</authToken>")); + + // Redacted after save + try (JenkinsRule.WebClient wc = j.createWebClient().login(EXTENDED_READER_USERNAME)) { + final XmlPage xmlPage = wc.goToXml(fs.getUrl() + "config.xml"); + assertThat(xmlPage.getWebResponse().getContentAsString(), containsString("<authToken>********</authToken>")); + } + } + } + + @Test + void testPostConfigXmlAndClearing() throws Exception { + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + j.jenkins.setAuthorizationStrategy(new MockAuthorizationStrategy().grant(Jenkins.READ).everywhere().toEveryone().grant(Jenkins.ADMINISTER).everywhere().to(ADMIN_USERNAME)); + + assertThat(j.jenkins.getAdministrativeMonitor("OldData").isActivated(), equalTo(false)); + + final FreeStyleProject freeStyleProject = j.createFreeStyleProject(); + + try (JenkinsRule.WebClient wc = j.createWebClient().login(ADMIN_USERNAME)) { + { + // POST config.xml with plain token + final WebRequest webRequest = new WebRequest(new URL(j.getURL(), freeStyleProject.getUrl() + "config.xml?" + + j.jenkins.getCrumbIssuer().getDescriptor().getCrumbRequestField() + "=" + j.jenkins.getCrumbIssuer().getCrumb((ServletRequest) null)), + HttpMethod.POST); + webRequest.setAdditionalHeader("Content-Type", "application/xml"); + webRequest.setRequestBody(IOUtils.resourceToString("/" + getClass().getName().replace(".", "/") + "/job-config-with-plain-token.xml", StandardCharsets.UTF_8)); + final Page apiPage = wc.getPage(webRequest); + assertThat(apiPage.getWebResponse().getStatusCode(), equalTo(200)); + } + + assertThat(j.jenkins.getAdministrativeMonitor("OldData").isActivated(), equalTo(true)); + + { + // Testing that GET config.xml does not clear the in-memory flag via ConverterImpl + final XmlPage xmlPage = wc.goToXml(freeStyleProject.getUrl() + "config.xml"); + assertThat(xmlPage.getWebResponse().getContentAsString(), containsString("plain_token")); + + assertThat(freeStyleProject.getConfigFile().asString(), containsString("plain_token")); + } + + // Saving clears the admin monitor + freeStyleProject.save(); + assertThat(j.jenkins.getAdministrativeMonitor("OldData").isActivated(), equalTo(false)); + + { + final XmlPage xmlPage = wc.goToXml(freeStyleProject.getUrl() + "config.xml"); + assertThat(xmlPage.getWebResponse().getContentAsString(), not(containsString("plain_token"))); + assertThat(freeStyleProject.getConfigFile().asString(), not(containsString("plain_token"))); + } + + } + } +}
test/src/test/resources/hudson/model/BuildAuthorizationTokenMigrationTest/basicMigration/jobs/job_without_token/config.xml+17 −0 added@@ -0,0 +1,17 @@ +<?xml version='1.1' encoding='UTF-8'?> +<project> + <actions/> + <description></description> + <keepDependencies>false</keepDependencies> + <properties/> + <scm class="hudson.scm.NullSCM"/> + <canRoam>true</canRoam> + <disabled>false</disabled> + <blockBuildWhenDownstreamBuilding>false</blockBuildWhenDownstreamBuilding> + <blockBuildWhenUpstreamBuilding>false</blockBuildWhenUpstreamBuilding> + <triggers/> + <concurrentBuild>false</concurrentBuild> + <builders/> + <publishers/> + <buildWrappers/> +</project> \ No newline at end of file
test/src/test/resources/hudson/model/BuildAuthorizationTokenMigrationTest/basicMigration/jobs/my_freestyle_job/config.xml+18 −0 added@@ -0,0 +1,18 @@ +<?xml version='1.1' encoding='UTF-8'?> +<project> + <actions/> + <description></description> + <keepDependencies>false</keepDependencies> + <properties/> + <scm class="hudson.scm.NullSCM"/> + <canRoam>true</canRoam> + <disabled>false</disabled> + <blockBuildWhenDownstreamBuilding>false</blockBuildWhenDownstreamBuilding> + <blockBuildWhenUpstreamBuilding>false</blockBuildWhenUpstreamBuilding> + <authToken>oldtoken</authToken> + <triggers/> + <concurrentBuild>false</concurrentBuild> + <builders/> + <publishers/> + <buildWrappers/> +</project> \ No newline at end of file
test/src/test/resources/hudson/model/BuildAuthorizationTokenMigrationTest/job-config-with-plain-token.xml+18 −0 added@@ -0,0 +1,18 @@ +<?xml version='1.1' encoding='UTF-8'?> +<project> + <actions/> + <description></description> + <keepDependencies>false</keepDependencies> + <properties/> + <scm class="hudson.scm.NullSCM"/> + <canRoam>true</canRoam> + <disabled>false</disabled> + <blockBuildWhenDownstreamBuilding>false</blockBuildWhenDownstreamBuilding> + <blockBuildWhenUpstreamBuilding>false</blockBuildWhenUpstreamBuilding> + <authToken>plain_token</authToken> + <triggers/> + <concurrentBuild>false</concurrentBuild> + <builders/> + <publishers/> + <buildWrappers/> +</project> \ No newline at end of file
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-hxjg-2jvf-h3rxghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-67638ghsaADVISORY
- www.jenkins.io/security/advisory/2025-12-10/ghsavendor-advisoryWEB
- github.com/jenkinsci/jenkins/commit/4710d65339251aaf1d1599f19545db99be24d981ghsaWEB
News mentions
0No linked articles in our index yet.