Skip to content
This repository has been archived by the owner. It is now read-only.
Permalink
master
Go to file
 
 
Cannot retrieve contributors at this time
217 lines (198 sloc) 9.97 KB
package eu.righettod.poccsrf.filter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpServletResponseWrapper;
import javax.xml.bind.DatatypeConverter;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.security.SecureRandom;
import java.util.Arrays;
/**
* Filter in charge of validating each incoming HTTP request about Headers and CSRF token.
* It is called for all requests to backend destination.
*
* We use the approach in which:
* - The CSRF token is changed after each valid HTTP exchange
* - The custom Header name for the CSRF token transmission is fixed
* - A CSRF token is associated to a backend service URI in order to enable the support for multiple parallel Ajax request from the same application
* - The CSRF cookie name is the backend service name prefixed with a fixed prefix
*
* Here for the POC we show the "access denied" reason in the response but in production code only return a generic message !!!
*
* @see "https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet"
* @see "https://wiki.mozilla.org/Security/Origin"
* @see "https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie"
* @see "https://chloe.re/2016/04/13/goodbye-csrf-samesite-to-the-rescue/"
*/
@WebFilter("/backend/*")
public class CSRFValidationFilter implements Filter {
/**
* JVM param name used to define the target origin
*/
public static final String TARGET_ORIGIN_JVM_PARAM_NAME = "target.origin";
/**
* Name of the custom HTTP header used to transmit the CSRF token and also to prefix the CSRF cookie for the expected backend service
*/
private static final String CSRF_TOKEN_NAME = "X-TOKEN";
/**
* Logger
*/
private static final Logger LOG = LoggerFactory.getLogger(CSRFValidationFilter.class);
/**
* Application expected deployment domain: named "Target Origin" in OWASP CSRF article
*/
private URL targetOrigin;
/***
* Secure generator
*/
private final SecureRandom secureRandom = new SecureRandom();
/**
* {@inheritDoc}
*/
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpReq = (HttpServletRequest) request;
HttpServletResponse httpResp = (HttpServletResponse) response;
String accessDeniedReason;
/* STEP 1: Verifying Same Origin with Standard Headers */
//Try to get the source from the "Origin" header
String source = httpReq.getHeader("Origin");
if (this.isBlank(source)) {
//If empty then fallback on "Referer" header
source = httpReq.getHeader("Referer");
//If this one is empty too then we trace the event and we block the request (recommendation of the article)...
if (this.isBlank(source)) {
accessDeniedReason = "CSRFValidationFilter: ORIGIN and REFERER request headers are both absent/empty so we block the request !";
LOG.warn(accessDeniedReason);
httpResp.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedReason);
return;
}
}
//Compare the source against the expected target origin
URL sourceURL = new URL(source);
if (!this.targetOrigin.getProtocol().equals(sourceURL.getProtocol()) || !this.targetOrigin.getHost().equals(sourceURL.getHost()) || this.targetOrigin.getPort() != sourceURL.getPort()) {
//One the part do not match so we trace the event and we block the request
accessDeniedReason = String.format("CSRFValidationFilter: Protocol/Host/Port do not fully matches so we block the request! (%s != %s) ", this.targetOrigin, sourceURL);
LOG.warn(accessDeniedReason);
httpResp.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedReason);
return;
}
/* STEP 2: Verifying CSRF token using "Double Submit Cookie" approach */
//If CSRF token cookie is absent from the request then we provide one in response but we stop the process at this stage.
//Using this way we implement the first providing of token
Cookie tokenCookie = null;
if (httpReq.getCookies() != null) {
String csrfCookieExpectedName = this.determineCookieName(httpReq);
tokenCookie = Arrays.stream(httpReq.getCookies()).filter(c -> c.getName().equals(csrfCookieExpectedName)).findFirst().orElse(null);
}
if (tokenCookie == null || this.isBlank(tokenCookie.getValue())) {
LOG.info("CSRFValidationFilter: CSRF cookie absent or value is null/empty so we provide one and return an HTTP NO_CONTENT response !");
//Add the CSRF token cookie and header
this.addTokenCookieAndHeader(httpReq, httpResp);
//Set response state to "204 No Content" in order to allow the requester to clearly identify an initial response providing the initial CSRF token
httpResp.setStatus(HttpServletResponse.SC_NO_CONTENT);
} else {
//If the cookie is present then we pass to validation phase
//Get token from the custom HTTP header (part under control of the requester)
String tokenFromHeader = httpReq.getHeader(CSRF_TOKEN_NAME);
//If empty then we trace the event and we block the request
if (this.isBlank(tokenFromHeader)) {
accessDeniedReason = "CSRFValidationFilter: Token provided via HTTP Header is absent/empty so we block the request !";
LOG.warn(accessDeniedReason);
httpResp.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedReason);
} else if (!tokenFromHeader.equals(tokenCookie.getValue())) {
//Verify that token from header and one from cookie are the same
//Here is not the case so we trace the event and we block the request
accessDeniedReason = "CSRFValidationFilter: Token provided via HTTP Header and via Cookie are not equals so we block the request !";
LOG.warn(accessDeniedReason);
httpResp.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedReason);
} else {
//Verify that token from header and one from cookie matches
//Here is the case so we let the request reach the target component (ServiceServlet, jsp...) and add a new token when we get back the bucket
HttpServletResponseWrapper httpRespWrapper = new HttpServletResponseWrapper(httpResp);
chain.doFilter(request, httpRespWrapper);
//Add the CSRF token cookie and header
this.addTokenCookieAndHeader(httpReq, httpRespWrapper);
}
}
}
/**
* {@inheritDoc}
*/
@Override
public void init(FilterConfig filterConfig) throws ServletException {
//To easier the configuration, we load the target expected origin from an JVM property
//Reconfiguration only require an application restart that is generally acceptable
try {
this.targetOrigin = new URL(System.getProperty(TARGET_ORIGIN_JVM_PARAM_NAME));
} catch (MalformedURLException e) {
LOG.error("Cannot init the filter !", e);
throw new ServletException(e);
}
LOG.info("CSRFValidationFilter: Filter init, set expected target origin to '{}'.", this.targetOrigin);
}
/**
* {@inheritDoc}
*/
@Override
public void destroy() {
LOG.info("CSRFValidationFilter: Filter shutdown");
}
/**
* Check if a string is null or empty (including containing only spaces)
*
* @param s Source string
* @return TRUE if source string is null or empty (including containing only spaces)
*/
private boolean isBlank(String s) {
return s == null || s.trim().isEmpty();
}
/**
* Generate a new CSRF token
*
* @return The token a string
*/
private String generateToken() {
byte[] buffer = new byte[50];
this.secureRandom.nextBytes(buffer);
return DatatypeConverter.printHexBinary(buffer);
}
/**
* Determine the name of the CSRF cookie for the targeted backend service
*
* @param httpRequest Source HTTP request
* @return The name of the cookie as a string
*/
private String determineCookieName(HttpServletRequest httpRequest) {
String backendServiceName = httpRequest.getRequestURI().replaceAll("/", "-");
return CSRF_TOKEN_NAME + "-" + backendServiceName;
}
/**
* Add the CSRF token cookie and header to the provided HTTP response object
*
* @param httpRequest Source HTTP request
* @param httpResponse HTTP response object to update
*/
private void addTokenCookieAndHeader(HttpServletRequest httpRequest, HttpServletResponse httpResponse) {
//Get new token
String token = this.generateToken();
//Add cookie manually because the current Cookie class implementation do not support the "SameSite" attribute
//We let the adding of the "Secure" cookie attribute to the reverse proxy rewriting...
//Here we lock the cookie from JS access and we use the SameSite new attribute protection
String cookieSpec = String.format("%s=%s; Path=%s; HttpOnly; SameSite=Strict", this.determineCookieName(httpRequest), token, httpRequest.getRequestURI());
httpResponse.addHeader("Set-Cookie", cookieSpec);
//Add cookie header to give access to the token to the JS code
httpResponse.setHeader(CSRF_TOKEN_NAME, token);
}
}