Author: violetagg Date: Fri Oct 16 22:27:47 2015 New Revision: 1709120 URL: http://svn.apache.org/viewvc?rev=1709120&view=rev Log: Basic implementation for CSRF protection for REST. Documentation will follow.
Added: tomcat/trunk/java/org/apache/catalina/filters/RestCsrfPreventionFilter.java (with props) tomcat/trunk/test/org/apache/catalina/filters/TestRestCsrfPreventionFilter.java (with props) Modified: tomcat/trunk/java/org/apache/catalina/filters/Constants.java tomcat/trunk/java/org/apache/catalina/filters/LocalStrings.properties Modified: tomcat/trunk/java/org/apache/catalina/filters/Constants.java URL: http://svn.apache.org/viewvc/tomcat/trunk/java/org/apache/catalina/filters/Constants.java?rev=1709120&r1=1709119&r2=1709120&view=diff ============================================================================== --- tomcat/trunk/java/org/apache/catalina/filters/Constants.java (original) +++ tomcat/trunk/java/org/apache/catalina/filters/Constants.java Fri Oct 16 22:27:47 2015 @@ -32,4 +32,13 @@ public final class Constants { "org.apache.catalina.filters.CSRF_NONCE"; public static final String METHOD_GET = "GET"; + + public static final String CSRF_REST_NONCE_HEADER_NAME = "X-CSRF-Token"; + + public static final String CSRF_REST_NONCE_HEADER_FETCH_VALUE = "Fetch"; + + public static final String CSRF_REST_NONCE_HEADER_REQUIRED_VALUE = "Required"; + + public static final String CSRF_REST_NONCE_SESSION_ATTR_NAME = + "org.apache.catalina.filters.CSRF_REST_NONCE"; } Modified: tomcat/trunk/java/org/apache/catalina/filters/LocalStrings.properties URL: http://svn.apache.org/viewvc/tomcat/trunk/java/org/apache/catalina/filters/LocalStrings.properties?rev=1709120&r1=1709119&r2=1709120&view=diff ============================================================================== --- tomcat/trunk/java/org/apache/catalina/filters/LocalStrings.properties (original) +++ tomcat/trunk/java/org/apache/catalina/filters/LocalStrings.properties Fri Oct 16 22:27:47 2015 @@ -48,3 +48,4 @@ httpHeaderSecurityFilter.committed=Unabl httpHeaderSecurityFilter.clickjack.invalid=An invalid value [{0}] was specified for the anti click-jacking header remoteIpFilter.invalidLocation=Failed to modify the rewrite location [{0}] to use scheme [{1}] and port [{2}] +restCsrfPreventionFilter.invalidNonce=CSRF nonce validation failed \ No newline at end of file Added: tomcat/trunk/java/org/apache/catalina/filters/RestCsrfPreventionFilter.java URL: http://svn.apache.org/viewvc/tomcat/trunk/java/org/apache/catalina/filters/RestCsrfPreventionFilter.java?rev=1709120&view=auto ============================================================================== --- tomcat/trunk/java/org/apache/catalina/filters/RestCsrfPreventionFilter.java (added) +++ tomcat/trunk/java/org/apache/catalina/filters/RestCsrfPreventionFilter.java Fri Oct 16 22:27:47 2015 @@ -0,0 +1,183 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.catalina.filters; + +import java.io.IOException; +import java.util.Objects; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; + +/** + * Provides basic CSRF protection for REST APIs. + * The filter assumes that: + * <ul> + * <li>The filter is mapped to /*</li> + * <li>The clients have adapted the transfer of the nonce through the 'X-CSRF-Token' header.</li> + * </ul> + * + * <pre> + * Positive scenario: + * Client Server + * | | + * | GET Fetch Request \| JSESSIONID + * |---------------------------------| X-CSRF-Token + * | /| pair generation + * |/Response to Fetch Request | + * |---------------------------------| + * JSESSIONID |\ | + * X-CSRF-Token | | + * pair cached | POST Request with valid nonce \| JSESSIONID + * |---------------------------------| X-CSRF-Token + * | /| pair validation + * |/ Response to POST Request | + * |---------------------------------| + * |\ | + * + * Negative scenario: + * Client Server + * | | + * | POST Request without nonce \| JSESSIONID + * |---------------------------------| X-CSRF-Token + * | /| pair validation + * |/Request is rejected | + * |---------------------------------| + * |\ | + * + * Client Server + * | | + * | POST Request with invalid nonce\| JSESSIONID + * |---------------------------------| X-CSRF-Token + * | /| pair validation + * |/Request is rejected | + * |---------------------------------| + * |\ | + * </pre> + */ +public class RestCsrfPreventionFilter extends CsrfPreventionFilterBase { + private static enum MethodType { + NON_MODIFYING_METHOD, MODIFYING_METHOD + } + + private static final Pattern NON_MODIFYING_METHODS_PATTERN = Pattern.compile("GET|HEAD|OPTIONS"); + private static final Predicate<String> nonModifyingMethods = m -> m != null && + NON_MODIFYING_METHODS_PATTERN.matcher(m).matches(); + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + + if (request instanceof HttpServletRequest && + response instanceof HttpServletResponse) { + MethodType mType = MethodType.MODIFYING_METHOD; + if (nonModifyingMethods.test(((HttpServletRequest) request).getMethod())) { + mType = MethodType.NON_MODIFYING_METHOD; + } + + RestCsrfPreventionStrategy strategy; + switch (mType) { + case NON_MODIFYING_METHOD: + strategy = new FetchRequest(); + break; + default: + strategy = new StateChangingRequest(); + break; + } + + if (!strategy.apply((HttpServletRequest) request, (HttpServletResponse) response)) { + return; + } + } + chain.doFilter(request, response); + } + + private static interface RestCsrfPreventionStrategy { + static final NonceSupplier<HttpServletRequest> nonceFromRequest = (r, k) -> r.getHeader(k); + static final NonceSupplier<HttpSession> nonceFromSession = (s, k) -> Objects.isNull(s) ? null + : (String) s.getAttribute(k); + + static final NonceConsumer<HttpServletResponse> nonceToResponse = (r, k, v) -> r.setHeader( + k, v); + static final NonceConsumer<HttpSession> nonceToSession = (s, k, v) -> s.setAttribute(k, v); + + boolean apply(HttpServletRequest request, HttpServletResponse response) throws IOException; + } + + private class StateChangingRequest implements RestCsrfPreventionStrategy { + + @Override + public boolean apply(HttpServletRequest request, HttpServletResponse response) + throws IOException { + if (isValidStateChangingRequest( + nonceFromRequest.getNonce(request, Constants.CSRF_REST_NONCE_HEADER_NAME), + nonceFromSession.getNonce(request.getSession(false), Constants.CSRF_REST_NONCE_SESSION_ATTR_NAME))) { + return true; + } + + nonceToResponse.setNonce(response, Constants.CSRF_REST_NONCE_HEADER_NAME, + Constants.CSRF_REST_NONCE_HEADER_REQUIRED_VALUE); + response.sendError(getDenyStatus(), + sm.getString("restCsrfPreventionFilter.invalidNonce")); + return false; + } + + private boolean isValidStateChangingRequest(String reqNonce, String sessionNonce) { + return Objects.nonNull(reqNonce) && Objects.nonNull(sessionNonce) + && Objects.equals(reqNonce, sessionNonce); + } + } + + private class FetchRequest implements RestCsrfPreventionStrategy { + private final Predicate<String> fetchRequest = s -> Constants.CSRF_REST_NONCE_HEADER_FETCH_VALUE + .equalsIgnoreCase(s); + + @Override + public boolean apply(HttpServletRequest request, HttpServletResponse response) { + if (fetchRequest.test( + nonceFromRequest.getNonce(request, Constants.CSRF_REST_NONCE_HEADER_NAME))) { + String nonceFromSessionStr = nonceFromSession.getNonce(request.getSession(false), + Constants.CSRF_REST_NONCE_SESSION_ATTR_NAME); + if (nonceFromSessionStr == null) { + nonceFromSessionStr = generateNonce(); + nonceToSession.setNonce(Objects.requireNonNull(request.getSession(true)), + Constants.CSRF_REST_NONCE_SESSION_ATTR_NAME, nonceFromSessionStr); + } + nonceToResponse.setNonce(response, Constants.CSRF_REST_NONCE_HEADER_NAME, + nonceFromSessionStr); + } + return true; + } + + } + + @FunctionalInterface + private static interface NonceSupplier<T> { + String getNonce(T supplier, String key); + } + + @FunctionalInterface + private static interface NonceConsumer<T> { + void setNonce(T consumer, String key, String value); + } +} Propchange: tomcat/trunk/java/org/apache/catalina/filters/RestCsrfPreventionFilter.java ------------------------------------------------------------------------------ svn:eol-style = native Added: tomcat/trunk/test/org/apache/catalina/filters/TestRestCsrfPreventionFilter.java URL: http://svn.apache.org/viewvc/tomcat/trunk/test/org/apache/catalina/filters/TestRestCsrfPreventionFilter.java?rev=1709120&view=auto ============================================================================== --- tomcat/trunk/test/org/apache/catalina/filters/TestRestCsrfPreventionFilter.java (added) +++ tomcat/trunk/test/org/apache/catalina/filters/TestRestCsrfPreventionFilter.java Fri Oct 16 22:27:47 2015 @@ -0,0 +1,236 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.catalina.filters; + +import java.io.IOException; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; + +import static org.junit.Assert.assertTrue; + +import org.junit.Before; +import org.junit.Test; + +import org.easymock.EasyMock; + +public class TestRestCsrfPreventionFilter { + + private static final String NONCE = "nonce"; + + private static final String INVALID_NONCE = "invalid-nonce"; + + private static final String GET_METHOD = "GET"; + + private static final String POST_METHOD = "POST"; + + private RestCsrfPreventionFilter filter; + + private TesterRequest request; + + private TesterResponse response; + + private TesterFilterChain filterChain; + + private HttpSession session; + + @Before + public void setUp() { + filter = new RestCsrfPreventionFilter() { + @Override + protected String generateNonce() { + return NONCE; + } + }; + request = new TesterRequest(); + response = new TesterResponse(); + filterChain = new TesterFilterChain(); + session = EasyMock.createMock(HttpSession.class); + } + + @Test + public void testGetRequestNoSessionNoNonce() throws Exception { + setRequestExpectations(GET_METHOD, null, null); + filter.doFilter(request, response, filterChain); + verifyContinueChain(); + } + + @Test + public void testPostRequestNoSessionNoNonce() throws Exception { + setRequestExpectations(POST_METHOD, null, null); + filter.doFilter(request, response, filterChain); + verifyDenyResponse(HttpServletResponse.SC_FORBIDDEN); + } + + @Test + public void testPostRequestSessionNoNonce1() throws Exception { + setRequestExpectations(POST_METHOD, session, null); + EasyMock.expect(session.getAttribute(Constants.CSRF_REST_NONCE_SESSION_ATTR_NAME)) + .andReturn(null); + EasyMock.replay(session); + filter.doFilter(request, response, filterChain); + verifyDenyResponse(HttpServletResponse.SC_FORBIDDEN); + EasyMock.verify(session); + } + + @Test + public void testPostRequestSessionNoNonce2() throws Exception { + setRequestExpectations(POST_METHOD, session, null); + EasyMock.expect(session.getAttribute(Constants.CSRF_REST_NONCE_SESSION_ATTR_NAME)) + .andReturn(NONCE); + EasyMock.replay(session); + filter.doFilter(request, response, filterChain); + verifyDenyResponse(HttpServletResponse.SC_FORBIDDEN); + EasyMock.verify(session); + } + + @Test + public void testPostRequestSessionInvalidNonce() throws Exception { + setRequestExpectations(POST_METHOD, session, INVALID_NONCE); + EasyMock.expect(session.getAttribute(Constants.CSRF_REST_NONCE_SESSION_ATTR_NAME)) + .andReturn(NONCE); + EasyMock.replay(session); + filter.doFilter(request, response, filterChain); + verifyDenyResponse(HttpServletResponse.SC_FORBIDDEN); + EasyMock.verify(session); + } + + @Test + public void testPostRequestSessionValidNonce() throws Exception { + setRequestExpectations(POST_METHOD, session, NONCE); + EasyMock.expect(session.getAttribute(Constants.CSRF_REST_NONCE_SESSION_ATTR_NAME)) + .andReturn(NONCE); + EasyMock.replay(session); + filter.doFilter(request, response, filterChain); + verifyContinueChain(); + EasyMock.verify(session); + } + + @Test + public void testGetFetchRequestSessionNoNonce() throws Exception { + setRequestExpectations(GET_METHOD, session, Constants.CSRF_REST_NONCE_HEADER_FETCH_VALUE); + EasyMock.expect(session.getAttribute(Constants.CSRF_REST_NONCE_SESSION_ATTR_NAME)) + .andReturn(null); + session.setAttribute(Constants.CSRF_REST_NONCE_SESSION_ATTR_NAME, NONCE); + EasyMock.expectLastCall(); + EasyMock.replay(session); + filter.doFilter(request, response, filterChain); + verifyContinueChainNonceAvailable(); + EasyMock.verify(session); + } + + @Test + public void testPostFetchRequestSessionNoNonce() throws Exception { + setRequestExpectations(POST_METHOD, session, Constants.CSRF_REST_NONCE_HEADER_FETCH_VALUE); + EasyMock.expect(session.getAttribute(Constants.CSRF_REST_NONCE_SESSION_ATTR_NAME)) + .andReturn(null); + EasyMock.replay(session); + filter.doFilter(request, response, filterChain); + verifyDenyResponse(HttpServletResponse.SC_FORBIDDEN); + EasyMock.verify(session); + } + + @Test + public void testGetFetchRequestSessionNonce() throws Exception { + setRequestExpectations(GET_METHOD, session, Constants.CSRF_REST_NONCE_HEADER_FETCH_VALUE); + EasyMock.expect(session.getAttribute(Constants.CSRF_REST_NONCE_SESSION_ATTR_NAME)) + .andReturn(NONCE); + EasyMock.replay(session); + filter.doFilter(request, response, filterChain); + verifyContinueChainNonceAvailable(); + EasyMock.verify(session); + } + + @Test + public void testPostFetchRequestSessionNonce() throws Exception { + setRequestExpectations(POST_METHOD, session, Constants.CSRF_REST_NONCE_HEADER_FETCH_VALUE); + EasyMock.expect(session.getAttribute(Constants.CSRF_REST_NONCE_SESSION_ATTR_NAME)) + .andReturn(NONCE); + EasyMock.replay(session); + filter.doFilter(request, response, filterChain); + verifyDenyResponse(HttpServletResponse.SC_FORBIDDEN); + EasyMock.verify(session); + } + + @Test + public void testPostRequestCustomDenyStatus() throws Exception { + setRequestExpectations(POST_METHOD, null, null); + filter.setDenyStatus(HttpServletResponse.SC_BAD_REQUEST); + filter.doFilter(request, response, filterChain); + verifyDenyResponse(HttpServletResponse.SC_BAD_REQUEST); + } + + private void setRequestExpectations(String method, HttpSession session, String headerValue) { + request.setMethod(method); + request.setSession(session); + request.setHeader(Constants.CSRF_REST_NONCE_HEADER_NAME, headerValue); + } + + private void verifyContinueChain() { + assertTrue(filterChain.isVisited()); + } + + private void verifyContinueChainNonceAvailable() { + assertTrue(NONCE.equals(response.getHeader(Constants.CSRF_REST_NONCE_HEADER_NAME))); + verifyContinueChain(); + } + + private void verifyDenyResponse(int statusCode) { + assertTrue(Constants.CSRF_REST_NONCE_HEADER_REQUIRED_VALUE.equals(response + .getHeader(Constants.CSRF_REST_NONCE_HEADER_NAME))); + assertTrue(statusCode == response.getStatus()); + assertTrue(!filterChain.isVisited()); + } + + private static class TesterFilterChain implements FilterChain { + private boolean visited = false; + + @Override + public void doFilter(ServletRequest request, ServletResponse response) throws IOException, + ServletException { + visited = true; + } + + boolean isVisited() { + return visited; + } + } + + private static class TesterRequest extends TesterHttpServletRequest { + private HttpSession session; + + void setSession(HttpSession session) { + this.session = session; + } + + @Override + public HttpSession getSession(boolean create) { + return session; + } + } + + private static class TesterResponse extends TesterHttpServletResponse { + @Override + public void sendError(int status, String message) throws IOException { + setStatus(status); + } + } +} Propchange: tomcat/trunk/test/org/apache/catalina/filters/TestRestCsrfPreventionFilter.java ------------------------------------------------------------------------------ svn:eol-style = native --------------------------------------------------------------------- To unsubscribe, e-mail: dev-unsubscr...@tomcat.apache.org For additional commands, e-mail: dev-h...@tomcat.apache.org