Security context
Medium· 5.3GHSA-4625-q52w-39cx CVE-2021-21609CWE-863Published May 24, 2022

Missing permission check for paths with specific prefix in Jenkins

Research this vulnerability

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

0 → fixed in 2.263.22.264 → fixed in 2.275

Details

Jenkins includes a static list of URLs that are always accessible even without Overall/Read permission, such as the login form. These URLs are excluded from an otherwise universal permission check. Jenkins 2.274 and earlier, LTS 2.263.1 and earlier does not correctly compare requested URLs with that list. This allows attackers without Overall/Read permission to access plugin-provided URLs with any of the following prefixes if no other permissions are required: - `accessDenied` - `error` - `instance-identity` - `login` - `logout` - `oops` - `securityRealm` - `signup` - `tcpSlaveAgentListener` For example, a plugin contributing the path `loginFoo/` would have URLs in that space accessible without the default Overall/Read permission check. The Jenkins security team is not aware of any affected plugins as of the publication of this advisory. The comparison of requested URLs with the list of always accessible URLs has been fixed to only allow access to the specific listed URLs in Jenkins 2.275, LTS 2.263.2. In case this change causes problems, additional paths can be made accessible without Overall/Read permissions: The [Java system property](https://www.jenkins.io/doc/book/managing/system-properties/) `jenkins.model.Jenkins.additionalReadablePaths` is a comma-separated list of additional path prefixes to allow access to.

The fix

[SECURITY-2047]

Jeff Thompson· Jan 11, 2021, 04:51 PM+11514fe9091fc74
core/src/main/java/jenkins/model/Jenkins.java+23 14
@@ -4851,7 +4851,7 @@ public Object getTarget() {
*/
public boolean isSubjectToMandatoryReadPermissionCheck(String restOfPath) {
for (String name : ALWAYS_READABLE_PATHS) {
- if (restOfPath.startsWith(name)) {
+ if (restOfPath.startsWith("/" + name + "/") || restOfPath.equals("/" + name)) {
return false;
}
}
@@ -5393,19 +5393,28 @@ public boolean shouldShowStackTrace() {
*
* <p>See also:{@link #getUnprotectedRootActions}.
*/
- private static final ImmutableSet<String> ALWAYS_READABLE_PATHS = ImmutableSet.of(
- "/login",
- "/logout",
- "/accessDenied",
- "/adjuncts/",
- "/error",
- "/oops",
- "/signup",
- "/tcpSlaveAgentListener",
- "/federatedLoginService/",
- "/securityRealm",
- "/instance-identity"
- );
+ private static final Set<String> ALWAYS_READABLE_PATHS = new HashSet<>(ImmutableSet.of(
+ "login",
+ "loginError",
+ "logout",
+ "accessDenied",
+ "adjuncts",
+ "error",
+ "oops",
+ "signup",
+ "tcpSlaveAgentListener",
+ "federatedLoginService",
+ "securityRealm",
+ "instance-identity"
+ ));
+
+ static {
+ final String paths = SystemProperties.getString(Jenkins.class.getName() + ".additionalReadablePaths");
+ if (paths != null) {
+ LOGGER.log(INFO, "SECURITY-2047 override: Adding the following paths to ALWAYS_READABLE_PATHS: " + paths);
+ ALWAYS_READABLE_PATHS.addAll(Arrays.stream(paths.split(",")).map(String::trim).collect(Collectors.toSet()));
+ }
+ }
/**
* {@link Authentication} object that represents the anonymous user.
test/src/test/java/jenkins/model/JenkinsSEC2047Test.java+83 0
@@ -0,0 +1,83 @@
+package jenkins.model;
+
+import com.gargoylesoftware.htmlunit.FailingHttpStatusCodeException;
+import com.gargoylesoftware.htmlunit.html.HtmlPage;
+import hudson.model.RootAction;
+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.JenkinsRule.WebClient;
+import org.jvnet.hudson.test.MockAuthorizationStrategy;
+import org.jvnet.hudson.test.TestExtension;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.is;
+import static org.junit.Assert.fail;
+
+//TODO merge back to JenkinsTest (or put it somewhere else)
+public class JenkinsSEC2047Test {
+
+ @Rule public JenkinsRule j = new JenkinsRule();
+
+ @Issue("SECURITY-2047")
+ @Test
+ public void testLogin123() throws Exception {
+ j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
+ j.jenkins.setAuthorizationStrategy(new MockAuthorizationStrategy());
+ WebClient wc = j.createWebClient();
+
+ try {
+ HtmlPage login123 = wc.goTo("login123");
+ fail("Page should be protected.");
+ } catch (FailingHttpStatusCodeException e) {
+ assertThat(e.getStatusCode(), is(403));
+ }
+ }
+
+ @Issue("SECURITY-2047")
+ @Test
+ public void testLogin123WithRead() throws Exception {
+ j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
+ j.jenkins.setAuthorizationStrategy(new MockAuthorizationStrategy().
+ grant(Jenkins.READ).everywhere().to("bob"));
+ WebClient wc = j.createWebClient();
+
+ wc.login("bob");
+ HtmlPage login123 = wc.goTo("login123");
+ assertThat(login123.getWebResponse().getStatusCode(), is(200));
+ assertThat(login123.getWebResponse().getContentAsString(), containsString("This should be protected"));
+ }
+
+ @Test
+ public void testLogin() throws Exception {
+ j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
+ j.jenkins.setAuthorizationStrategy(new MockAuthorizationStrategy().
+ grant(Jenkins.READ).everywhere().to("bob"));
+ WebClient wc = j.createWebClient();
+
+ HtmlPage login = wc.goTo("login");
+ assertThat(login.getWebResponse().getStatusCode(), is(200));
+ assertThat(login.getWebResponse().getContentAsString(), containsString("login"));
+ }
+
+ @TestExtension({"testLogin123", "testLogin123WithRead"})
+ public static class ProtectedRootAction implements RootAction {
+ @Override
+ public String getIconFileName() {
+ return "document.png";
+ }
+
+ @Override
+ public String getDisplayName() {
+ return "I am PROTECTED";
+ }
+
+ @Override
+ public String getUrlName() {
+ return "login123";
+ }
+ }
+
+}
test/src/test/resources/jenkins/model/JenkinsSEC2047Test/ProtectedRootAction/index.jelly+9 0
@@ -0,0 +1,9 @@
+<?jelly escape-by-default='true'?>
+<j:jelly xmlns:j="jelly:core" xmlns:l="/lib/layout">
+ <l:layout title="Protected Action">
+ <l:main-panel>
+ <h1>Protected Root Action</h1>
+ <p>This should be protected</p>
+ </l:main-panel>
+ </l:layout>
+</j:jelly>

References