Jenkins Diagnostic page exposed session cookies
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
Details
Jenkins shows various technical details about the current user on the `/whoAmI` page. In [a previous fix](https://www.jenkins.io/security/advisory/2019-09-25/#SECURITY-1505), the `Cookie` header value containing the HTTP session ID was redacted. However, user metadata shown on this page could also include the HTTP session ID in Jenkins 2.218 and earlier, LTS 2.204.1 and earlier. This allows attackers able to exploit a cross-site scripting vulnerability to obtain the HTTP session ID value from this page. Jenkins 2.219, LTS 2.204.2 no longer prints out the affected user metadata that might contain the HTTP session ID. Additionally, we also redact values of further authentication-related HTTP headers in addition to `Cookie` on this page as a hardening.
The fix
[SECURITY-1695][SECURITY-1697]
core/src/main/java/hudson/security/WhoAmI.java+23 −3
@@ -6,7 +6,12 @@import hudson.model.UnprotectedRootAction;import java.util.ArrayList;+import java.util.Arrays;+import java.util.Collections;+import java.util.HashSet;import java.util.List;+import java.util.Locale;+import java.util.Set;import jenkins.util.MemoryReductionUtil;import jenkins.model.Jenkins;@@ -14,9 +19,13 @@import org.acegisecurity.Authentication;import org.acegisecurity.GrantedAuthority;import org.jenkinsci.Symbol;+import org.kohsuke.accmod.Restricted;+import org.kohsuke.accmod.restrictions.NoExternalUse;import org.kohsuke.stapler.export.Exported;import org.kohsuke.stapler.export.ExportedBean;+import javax.annotation.Nonnull;+/*** Expose the data needed for /whoAmI, so it can be exposed by Api.*@@ -26,6 +35,11 @@@Extension @Symbol("whoAmI")@ExportedBeanpublic class WhoAmI implements UnprotectedRootAction {+private static final Set<String> dangerousHeaders = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(+"cookie",+// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers#Authentication+"authorization", "www-authenticate", "proxy-authenticate", "proxy-authorization"+)));public Api getApi() {return new Api(this);@@ -46,17 +60,17 @@ public boolean isAnonymous() {return Functions.isAnonymous();}-@Exported+// @Exported removed due to leak of sessionId with some SecurityRealmpublic String getDetails() {return auth().getDetails() != null ? auth().getDetails().toString() : null;}-@Exported+// @Exported removed due to leak of sessionId with some SecurityRealmpublic String getToString() {return auth().toString();}-private Authentication auth() {+private @Nonnull Authentication auth() {return Jenkins.getAuthentication();}@@ -72,6 +86,12 @@ public String[] getAuthorities() {return (String[]) authorities.toArray(new String[authorities.size()]);}+// Used by Jelly+@Restricted(NoExternalUse.class)+public boolean isHeaderDangerous(@Nonnull String name) {+return dangerousHeaders.contains(name.toLowerCase(Locale.ENGLISH));+}+@Overridepublic String getIconFileName() {return null;
core/src/main/resources/hudson/security/WhoAmI/index.jelly+22 −28
@@ -61,41 +61,35 @@ THE SOFTWARE.</ul></td></tr>-<tr>-<td>Details:</td>-<td>${auth.details}</td>-</tr>-<tr>-<td>toString:</td>-<td>${auth}</td>-</tr></table><h2>Request Headers</h2><table><j:forEach var="n" items="${request.getHeaderNames()}">-<j:if test="${n.equalsIgnoreCase('Cookie')}">-<tr>-<td rowspan="1">${n}</td>-<td>-<i>(redacted for security reasons)</i>-</td>-</tr>-</j:if>-<j:if test="${!n.equalsIgnoreCase('Cookie')}">-<j:set var="values" value="${h.getRequestHeaders(n)}"/>-<tr>-<td rowspan="${values.size()}">${n}</td>-<td>-${values[0]}-</td>-</tr>-<j:forEach var="v" items="${values.subList(1,values.size())}">+<j:choose>+<j:when test="${it.isHeaderDangerous(n)}" ><tr>-<td>${v}</td>+<td rowspan="1">${n}</td>+<td>+<i>(redacted for security reasons)</i>+</td></tr>-</j:forEach>-</j:if>+</j:when>+<j:otherwise>+<j:set var="values" value="${h.getRequestHeaders(n)}"/>+<tr>+<td rowspan="${values.size()}">${n}</td>+<td>+${values[0]}+</td>+</tr>+<j:forEach var="v" items="${values.subList(1,values.size())}">+<tr>+<td>${v}</td>+</tr>+</j:forEach>+</j:otherwise>+</j:choose></j:forEach></table></l:main-panel>
test/src/test/java/hudson/security/WhoAmITest.java+257 −0
@@ -0,0 +1,257 @@+/*+* The MIT License+*+* Copyright (c) 2020, CloudBees, Inc.+*+* Permission is hereby granted, free of charge, to any person obtaining a copy+* of this software and associated documentation files (the "Software"), to deal+* in the Software without restriction, including without limitation the rights+* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+* copies of the Software, and to permit persons to whom the Software is+* furnished to do so, subject to the following conditions:+*+* The above copyright notice and this permission notice shall be included in+* all copies or substantial portions of the Software.+*+* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN+* THE SOFTWARE.+*/++package hudson.security;++import com.gargoylesoftware.htmlunit.Page;+import com.gargoylesoftware.htmlunit.html.HtmlPage;+import hudson.model.User;+import jenkins.model.Jenkins;+import jenkins.security.ApiTokenProperty;+import jenkins.security.apitoken.ApiTokenStore;+import org.acegisecurity.AuthenticationException;+import org.acegisecurity.GrantedAuthority;+import org.acegisecurity.userdetails.UserDetails;+import org.acegisecurity.userdetails.UsernameNotFoundException;+import org.junit.Rule;+import org.junit.Test;+import org.jvnet.hudson.test.Issue;+import org.jvnet.hudson.test.JenkinsRule;+import org.jvnet.hudson.test.MockAuthorizationStrategy;+import org.kohsuke.stapler.Stapler;+import org.springframework.dao.DataAccessException;++import javax.servlet.http.HttpSession;++import java.nio.charset.StandardCharsets;+import java.util.Base64;++import static org.hamcrest.MatcherAssert.assertThat;+import static org.hamcrest.Matchers.anyOf;+import static org.hamcrest.Matchers.containsString;+import static org.hamcrest.Matchers.not;+import static org.hamcrest.Matchers.nullValue;++public class WhoAmITest {++@Rule+public final JenkinsRule j = new JenkinsRule();++@Test+@Issue("SECURITY-1695")+public void whoAmI_regular_doesNotProvideSensitiveInformation() throws Exception {+j.jenkins.setSecurityRealm(new SecurityRealmImpl());++j.jenkins.setAuthorizationStrategy(new MockAuthorizationStrategy()+.grant(Jenkins.READ).everywhere().to("user")+);++JenkinsRule.WebClient wc = j.createWebClient();+wc.login("user");++HtmlPage whoAmIPage = wc.goTo("whoAmI");+String content = whoAmIPage.getWebResponse().getContentAsString();++String sessionId = wc.executeOnServer(() -> {+HttpSession session = Stapler.getCurrentRequest().getSession(false);+return session != null ? session.getId() : null;+});++assertThat(sessionId, not(nullValue()));++// dangerous stuff in Regular Login mode:+/*+* <td>Details:</td>+* <td>org.acegisecurity.ui.WebAuthenticationDetails@12afc: RemoteIpAddress: 127.0.0.1; SessionId: node0gbmv9ly0f3h517eppoupykq6n0</td>+*+* <td>toString:</td>+* <td>org.acegisecurity.providers.UsernamePasswordAuthenticationToken@d35a1467: Username: [toString()=S3cr3t];+* Password: [PROTECTED]; Authenticated: true; Details:+* org.acegisecurity.ui.WebAuthenticationDetails@12afc: RemoteIpAddress: 127.0.0.1; SessionId:+* node0gbmv9ly0f3h517eppoupykq6n0; Granted Authorities:+* </td>+*/+assertThat(content, not(anyOf(+containsString("S3cr3t"),+containsString("SessionId"),+containsString(sessionId)+)));+}++@Test+@Issue("SECURITY-1695")+public void whoAmI_regularApi_doesNotProvideSensitiveInformation() throws Exception {+j.jenkins.setSecurityRealm(new SecurityRealmImpl());++j.jenkins.setAuthorizationStrategy(new MockAuthorizationStrategy()+.grant(Jenkins.READ).everywhere().to("user")+);++JenkinsRule.WebClient wc = j.createWebClient();+wc.login("user");++Page whoAmIPage = wc.goTo("whoAmI/api/json", "application/json");+String content = whoAmIPage.getWebResponse().getContentAsString();++String sessionId = wc.executeOnServer(() -> {+HttpSession session = Stapler.getCurrentRequest().getSession(false);+return session != null ? session.getId() : null;+});++assertThat(sessionId, not(nullValue()));++// dangerous stuff in Regular Login mode with the api/json call:+/*+* {+* "_class": "hudson.security.WhoAmI",+* "anonymous": false,+* "authenticated": true,+* "authorities": [],+* "details": "org.acegisecurity.ui.WebAuthenticationDetails@fffc7f0c: RemoteIpAddress: 127.0.0.1; SessionId: node0g4xbfaaq1qb91pwyv0ctilrfu0",+* "name": "user",+* "toString": "org.acegisecurity.providers.UsernamePasswordAuthenticationToken@66074b8a: Username: [toString()=S3cr3t]; Password: [PROTECTED]; Authenticated: true; Details: org.acegisecurity.ui.WebAuthenticationDetails@fffc7f0c: RemoteIpAddress: 127.0.0.1; SessionId: node0g4xbfaaq1qb91pwyv0ctilrfu0; Granted Authorities: "+* }+*/+assertThat(content, not(anyOf(+containsString("S3cr3t"),+containsString("SessionId"),+containsString(sessionId)+)));+}++@Test+@Issue("SECURITY-1697")+public void whoAmI_basic_doesNotProvideSensitiveInformation() throws Exception {+j.jenkins.setSecurityRealm(new SecurityRealmImpl());++j.jenkins.setAuthorizationStrategy(new MockAuthorizationStrategy()+.grant(Jenkins.READ).everywhere().to("user")+);++JenkinsRule.WebClient wc = j.createWebClient().withBasicCredentials("user", "user");++HtmlPage whoAmIPage = wc.goTo("whoAmI");+String content = whoAmIPage.getWebResponse().getContentAsString();++// dangerous stuff in Basic mode:+/*+* <td>toString:</td>+* <td>org.acegisecurity.providers.UsernamePasswordAuthenticationToken@e8fd00a7: Username: [toString()=S3cr3t];+*+* <td rowspan="1">Authorization</td>+* <td>Basic dXNlcjp1c2Vy</td>+*/+assertThat(content, not(anyOf(+containsString("S3cr3t"),+containsString("SessionId"),+// base64 of user:user+containsString(Base64.getEncoder().encodeToString("user:user".getBytes(StandardCharsets.UTF_8)))+)));+}++@Test+@Issue("SECURITY-1697")+public void whoAmI_apiToken_doesNotProvideSensitiveInformation() throws Exception {+j.jenkins.setSecurityRealm(new SecurityRealmImpl());++j.jenkins.setAuthorizationStrategy(new MockAuthorizationStrategy()+.grant(Jenkins.READ).everywhere().to("user")+);++User user = User.getById("user", true);+ApiTokenProperty prop = user.getProperty(ApiTokenProperty.class);+ApiTokenStore.TokenUuidAndPlainValue token = prop.getTokenStore().generateNewToken("test");++JenkinsRule.WebClient wc = j.createWebClient().withBasicCredentials("user", token.plainValue);+String base64ApiToken = new String(Base64.getEncoder().encode(("user:" + token.plainValue).getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8);++HtmlPage whoAmIPage = wc.goTo("whoAmI");+String content = whoAmIPage.getWebResponse().getContentAsString();++// dangerous stuff in API Token mode:+/*+* <td rowspan="1">Authorization</td>+* <td>Basic dXNlcjoxMTRiNGRmMWNhZTVkNDQ2MjgxZTJkZWEzMDY1NTEyZDBi</td>+*/+assertThat(content, not(anyOf(+containsString("S3cr3t"),+containsString("SessionId"),+containsString(base64ApiToken)+)));+}++private class SecurityRealmImpl extends AbstractPasswordBasedSecurityRealm {++@Override+protected UserDetails authenticate(String username, String password) throws AuthenticationException {+return createUserDetails(username);+}++@Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException, DataAccessException {+return createUserDetails(username);+}++@Override public GroupDetails loadGroupByGroupname(String groupname) throws UsernameNotFoundException, DataAccessException {+return null;+}++private UserDetails createUserDetails(String username) {+return new UserDetails() {++@Override public String getUsername() {+return username;+}++@Override+public String toString() {+return "[toString()=S3cr3t]";+}++@Override public GrantedAuthority[] getAuthorities() {+return new GrantedAuthority[0];+}++@Override public String getPassword() {+return null;+}++@Override public boolean isAccountNonExpired() {+return true;+}++@Override public boolean isAccountNonLocked() {+return true;+}++@Override public boolean isCredentialsNonExpired() {+return true;+}++@Override public boolean isEnabled() {+return true;+}+};+}+}+}
References
- ADVISORYhttps://nvd.nist.gov/vuln/detail/CVE-2020-2103
- WEBhttps://github.com/jenkinsci/jenkins/commit/77f36f37f5e3cabd0e4ece16d46d7943454ed15b
- WEBhttps://access.redhat.com/errata/RHBA-2020:0402
- WEBhttps://access.redhat.com/errata/RHBA-2020:0675
- WEBhttps://access.redhat.com/errata/RHSA-2020:0681
- WEBhttps://access.redhat.com/errata/RHSA-2020:0683
- PACKAGEhttps://github.com/jenkinsci/jenkins
- WEBhttps://jenkins.io/security/advisory/2020-01-29/#SECURITY-1695
- WEBhttp://www.openwall.com/lists/oss-security/2020/01/29/1