Jenkins's build authorization token is stored and displayed in plain text
Research is free — Hunters explains how the bug works, the root-cause code pattern, how the fix addresses it, and how to test whether a target is affected, in chat. Investigate & write exploit is a paid run — the engine reads the advisory and fix commits, then builds and validates a working proof-of-concept exploit with reproduction steps.
Affected versions
2.529 → fixed in 2.5410 → fixed in 2.528.3
Details
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.
The fix
[SECURITY-783]
core/src/main/java/hudson/model/BuildAuthorizationToken.java+71 −4
@@ -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 @@*/@Deprecatedpublic final class BuildAuthorizationToken {-private final String token;+private final Secret token;+private transient boolean fromPlaintext;++/**+* @deprecated since TODO+*/+@Deprecatedpublic 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 toif (token != null && token.token != null) {//check the provided tokenString 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 tocheckPermission(project, token, StaplerRequest.toStaplerRequest2(req), StaplerResponse.toStaplerResponse2(rsp));}+@Deprecatedpublic String getToken() {+return token.getPlainText();+}++/**+* @since TODO+*/+public Secret getEncryptedToken() {return token;}@@ -122,12 +157,44 @@ public boolean canConvert(Class type) {@Overridepublic Object fromString(String str) {-return new BuildAuthorizationToken(str);+if (Secret.decrypt(str) == null) {+return new BuildAuthorizationToken(str);+}+return new BuildAuthorizationToken(Secret.fromString(str));}@Overridepublic 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
@@ -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
@@ -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
@@ -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>
test/src/test/resources/hudson/model/BuildAuthorizationTokenMigrationTest/basicMigration/jobs/my_freestyle_job/config.xml+18 −0
@@ -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>
test/src/test/resources/hudson/model/BuildAuthorizationTokenMigrationTest/job-config-with-plain-token.xml+18 −0
@@ -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>