Skip to content

Commit

Permalink
[SECURITY-1774]
Browse files Browse the repository at this point in the history
  • Loading branch information
daniel-beck committed Mar 11, 2020
1 parent c2d22b2 commit f479652
Show file tree
Hide file tree
Showing 2 changed files with 177 additions and 1 deletion.
58 changes: 57 additions & 1 deletion core/src/main/java/hudson/security/csrf/CrumbFilter.java
Expand Up @@ -7,6 +7,7 @@

import hudson.util.MultipartFormDataParser;
import jenkins.model.Jenkins;
import jenkins.util.SystemProperties;
import org.acegisecurity.providers.anonymous.AnonymousAuthenticationToken;
import org.kohsuke.MetaInfServices;
import org.kohsuke.accmod.Restricted;
Expand All @@ -15,7 +16,10 @@
import org.kohsuke.stapler.interceptor.RequirePOST;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;

Expand All @@ -26,6 +30,7 @@
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import javax.servlet.http.HttpServletResponse;

/**
Expand Down Expand Up @@ -58,6 +63,54 @@ public ForwardToView getForwardView() {
public void init(FilterConfig filterConfig) throws ServletException {
}

private static class Security1774ServletRequest extends HttpServletRequestWrapper {
public Security1774ServletRequest(HttpServletRequest request) {
super(request);
}

@Override
public String getPathInfo() {
// see Stapler#getServletPath
return canonicalPath(getRequestURI().substring(getContextPath().length()));
}


// Copied from Stapler#canonicalPath
private static String canonicalPath(String path) {
List<String> r = new ArrayList<String>(Arrays.asList(path.split("/+")));
for (int i=0; i<r.size(); ) {
if (r.get(i).length()==0 || r.get(i).equals(".")) {
// empty token occurs for example, "".split("/+") is [""]
r.remove(i);
} else
if (r.get(i).equals("..")) {
// i==0 means this is a broken URI.
r.remove(i);
if (i>0) {
r.remove(i-1);
i--;
}
} else {
i++;
}
}

StringBuilder buf = new StringBuilder();
if (path.startsWith("/"))
buf.append('/');
boolean first = true;
for (String token : r) {
if (!first) buf.append('/');
else first = false;
buf.append(token);
}
// translation: if (path.endsWith("/") && !buf.endsWith("/"))
if (path.endsWith("/") && (buf.length()==0 || buf.charAt(buf.length()-1)!='/'))
buf.append('/');
return buf.toString();
}
}

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
CrumbIssuer crumbIssuer = getCrumbIssuer();
if (crumbIssuer == null || !(request instanceof HttpServletRequest)) {
Expand All @@ -69,8 +122,9 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha
HttpServletResponse httpResponse = (HttpServletResponse) response;

if ("POST".equals(httpRequest.getMethod())) {
HttpServletRequest wrappedRequest = UNPROCESSED_PATHINFO ? httpRequest : new Security1774ServletRequest(httpRequest);
for (CrumbExclusion e : CrumbExclusion.all()) {
if (e.process(httpRequest,httpResponse,chain))
if (e.process(wrappedRequest,httpResponse,chain))
return;
}

Expand Down Expand Up @@ -136,5 +190,7 @@ protected static boolean isMultipart(HttpServletRequest request) {
public void destroy() {
}

static /* non-final for Groovy */ boolean UNPROCESSED_PATHINFO = SystemProperties.getBoolean(CrumbFilter.class.getName() + ".UNPROCESSED_PATHINFO");

private static final Logger LOGGER = Logger.getLogger(CrumbFilter.class.getName());
}
120 changes: 120 additions & 0 deletions test/src/test/java/hudson/security/csrf/CrumbExclusionTest.java
@@ -0,0 +1,120 @@
/*
* The MIT License
*
* Copyright 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.csrf;

import com.gargoylesoftware.htmlunit.FailingHttpStatusCodeException;
import com.gargoylesoftware.htmlunit.HttpMethod;
import com.gargoylesoftware.htmlunit.WebRequest;

import java.io.IOException;
import java.net.URL;

import hudson.ExtensionList;
import hudson.model.UnprotectedRootAction;
import jenkins.model.Jenkins;
import static org.hamcrest.Matchers.containsString;

import org.junit.Assert;
import org.junit.Test;
import static org.junit.Assert.*;
import org.junit.Rule;
import org.jvnet.hudson.test.Issue;
import org.jvnet.hudson.test.JenkinsRule;
import org.jvnet.hudson.test.MockAuthorizationStrategy;
import org.jvnet.hudson.test.TestExtension;
import org.kohsuke.stapler.verb.POST;

import javax.annotation.CheckForNull;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class CrumbExclusionTest {

@Rule
public JenkinsRule r = new JenkinsRule();

@Issue("SECURITY-1774")
@Test
public void pathInfo() throws Exception {
r.jenkins.setSecurityRealm(r.createDummySecurityRealm());
r.jenkins.setAuthorizationStrategy(new MockAuthorizationStrategy().grant(Jenkins.ADMINISTER).everywhere().to("admin"));
for (String path : new String[] {/* control */ "scriptText", /* test */ "scriptText/..;/cli"}) {
try {
fail(path + " should have been rejected: " + r.createWebClient().login("admin").getPage(new WebRequest(new URL(r.getURL(), path + "?script=11*11"), HttpMethod.POST)).getWebResponse().getContentAsString());
} catch (FailingHttpStatusCodeException x) {
assertEquals("status code using " + path, 403, x.getStatusCode());
assertThat("error message using " + path, x.getResponse().getContentAsString(), containsString("No valid crumb was included in the request"));
}
}
}

@Test
public void regular() throws Exception {
r.createWebClient().getPage(new WebRequest(new URL(r.getURL(), "root/"), HttpMethod.POST));
Assert.assertTrue(ExtensionList.lookupSingleton(RootActionImpl.class).posted);
}

@TestExtension
public static class RootActionImpl implements UnprotectedRootAction {

public boolean posted = false;

@CheckForNull
public String getIconFileName() {
return null;
}

@CheckForNull
public String getDisplayName() {
return null;
}

@CheckForNull
public String getUrlName() {
return "root";
}

@POST
public void doIndex() {
posted = true;
}
}

@TestExtension
public static class CrumbExclusionImpl extends CrumbExclusion {

@Override
public boolean process(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
String pathInfo = request.getPathInfo();
if (pathInfo != null && pathInfo.startsWith("/root/")) {
chain.doFilter(request, response);
return true;
}
return false;
}
}
}

0 comments on commit f479652

Please sign in to comment.