Skip to content

BREACH attack protection for CSRF tokens #10778

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2018 the original author or authors.
* Copyright 2002-2022 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -555,6 +555,7 @@ public void match(MvcResult result) throws Exception {
CsrfToken token = WebTestUtils.getCsrfTokenRepository(request).loadToken(request);
assertThat(token).isNotNull();
assertThat(token.getToken()).isEqualTo(this.token.apply(result));
assertThat(token.matches(this.token.apply(result))).isTrue();
}

}
Expand Down
16 changes: 16 additions & 0 deletions docs/modules/ROOT/pages/features/exploits/csrf.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -412,3 +412,19 @@ Overriding the HTTP method occurs in a filter.
That filter must be placed before Spring Security's support.
Note that overriding happens only on a `post`, so this is actually unlikely to cause any real problems.
However, it is still best practice to ensure that it is placed before Spring Security's filters.

[[csrf-considerations-breach-mitigation]]
==== BREACH Security Exploit Mitigation

When a web server has HTTP compression enabled, web applications hosted on that server may be vulnerable to a https://en.wikipedia.org/wiki/BREACH[BREACH attack].
Any sensitive information displayed by the web application could become compromised, including a CSRF token.
As part of a defense in depth strategy, Spring Security provides BREACH mitigation support for CSRF tokens.
When enabled, a random string is always returned for the token value, which makes it more difficult to guess the csrf token when using HTTP compression.

NOTE: Spring Security cannot protect an application from BREACH attacks entirely, and only provides this protection as part of a defense in depth strategy.
There may be other areas of an application that are vulnerable, and therefore additional mitigation may be required.

Use of the BREACH mitigation support is an opt-in feature in Spring Security 5.7, and will only be enabled by default starting with the 6.0 release. The following `CsrfTokenRepository` implementations provide BREACH mitigation support via the `setXorRandomSecretEnabled(true)` method:

* `HttpSessionCsrfTokenRepository`
* `CookieCsrfTokenRepository`
57 changes: 57 additions & 0 deletions docs/modules/ROOT/pages/servlet/exploits/csrf.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -482,3 +482,60 @@ We have xref:features/exploits/csrf.adoc#csrf-considerations-multipart-body[alre

In Spring's Servlet support, overriding the HTTP method is done by using https://docs.spring.io/spring-framework/docs/5.2.x/javadoc-api/org/springframework/web/filter/reactive/HiddenHttpMethodFilter.html[`HiddenHttpMethodFilter`].
You can find more information in the https://docs.spring.io/spring/docs/5.2.x/spring-framework-reference/web.html#mvc-rest-method-conversion[HTTP Method Conversion] section of the reference documentation.

[[servlet-csrf-considerations-breach-mitigation]]
=== BREACH Security Exploit Mitigation

We have xref:features/exploits/csrf.adoc#csrf-considerations-breach-mitigation[already discussed] BREACH mitigation support for CSRF tokens.

In Spring’s Servlet support, we can override the default implementation as in the example below:

.Override implementation of `CsrfTokenRepository`
====
.Java
[source,java,role="primary"]
----
@EnableWebSecurity
public class WebSecurityConfig {

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(HttpSessionCsrfTokenRepository.withXorRandomSecretEnabled())
);
return http.build();
}
}
----

.Kotlin
[source,kotlin,role="secondary"]
----
@EnableWebSecurity
class SecurityConfig {

@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
http {
csrf {
csrfTokenRepository = HttpSessionCsrfTokenRepository.withXorRandomSecretEnabled()
}
}
return http.build()
}
}
----

.XML
[source,xml,role="secondary"]
----
<http>
<!-- ... -->
<csrf token-repository-ref="tokenRepository"/>
</http>
<b:bean id="tokenRepository"
class="org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository"
p:xorRandomSecretEnabled="true"/>
----
====
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2014 the original author or authors.
* Copyright 2002-2022 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -58,6 +58,7 @@ public void defaults() {
assertThat(request.getParameter("password")).isEqualTo("password");
assertThat(request.getMethod()).isEqualTo("POST");
assertThat(request.getParameter(token.getParameterName())).isEqualTo(token.getToken());
assertThat(token.matches(request.getParameter(token.getParameterName()))).isTrue();
assertThat(request.getRequestURI()).isEqualTo("/login");
assertThat(request.getParameter("_csrf")).isNotNull();
}
Expand All @@ -72,6 +73,7 @@ public void custom() {
assertThat(request.getParameter("password")).isEqualTo("secret");
assertThat(request.getMethod()).isEqualTo("POST");
assertThat(request.getParameter(token.getParameterName())).isEqualTo(token.getToken());
assertThat(token.matches(request.getParameter(token.getParameterName()))).isTrue();
assertThat(request.getRequestURI()).isEqualTo("/login");
}

Expand All @@ -85,6 +87,7 @@ public void customWithUriVars() {
assertThat(request.getParameter("password")).isEqualTo("secret");
assertThat(request.getMethod()).isEqualTo("POST");
assertThat(request.getParameter(token.getParameterName())).isEqualTo(token.getToken());
assertThat(token.matches(request.getParameter(token.getParameterName()))).isTrue();
assertThat(request.getRequestURI()).isEqualTo("/uri-login/val1/val2");
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2014 the original author or authors.
* Copyright 2002-2022 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -56,6 +56,7 @@ public void defaults() {
.getAttribute(CsrfRequestPostProcessor.TestCsrfTokenRepository.TOKEN_ATTR_NAME);
assertThat(request.getMethod()).isEqualTo("POST");
assertThat(request.getParameter(token.getParameterName())).isEqualTo(token.getToken());
assertThat(token.matches(request.getParameter(token.getParameterName()))).isTrue();
assertThat(request.getRequestURI()).isEqualTo("/logout");
}

Expand All @@ -66,6 +67,7 @@ public void custom() {
.getAttribute(CsrfRequestPostProcessor.TestCsrfTokenRepository.TOKEN_ATTR_NAME);
assertThat(request.getMethod()).isEqualTo("POST");
assertThat(request.getParameter(token.getParameterName())).isEqualTo(token.getToken());
assertThat(token.matches(request.getParameter(token.getParameterName()))).isTrue();
assertThat(request.getRequestURI()).isEqualTo("/admin/logout");
}

Expand All @@ -77,6 +79,7 @@ public void customWithUriVars() {
.getAttribute(CsrfRequestPostProcessor.TestCsrfTokenRepository.TOKEN_ATTR_NAME);
assertThat(request.getMethod()).isEqualTo("POST");
assertThat(request.getParameter(token.getParameterName())).isEqualTo(token.getToken());
assertThat(token.matches(request.getParameter(token.getParameterName()))).isTrue();
assertThat(request.getRequestURI()).isEqualTo("/uri-logout/val1/val2");
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2012-2016 the original author or authors.
* Copyright 2012-2022 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -16,6 +16,7 @@

package org.springframework.security.web.csrf;

import java.security.SecureRandom;
import java.util.UUID;

import jakarta.servlet.ServletRequest;
Expand Down Expand Up @@ -59,17 +60,26 @@ public final class CookieCsrfTokenRepository implements CsrfTokenRepository {

private int cookieMaxAge = -1;

private SecureRandom secureRandom;

public CookieCsrfTokenRepository() {
}

private CookieCsrfTokenRepository(SecureRandom secureRandom) {
Assert.notNull(secureRandom, "secureRandom cannot be null");
this.secureRandom = secureRandom;
}

@Override
public CsrfToken generateToken(HttpServletRequest request) {
return new DefaultCsrfToken(this.headerName, this.parameterName, createNewToken());
return this.isXorRandomSecretEnabled()
? new DefaultCsrfToken(this.headerName, this.parameterName, createNewToken(), this.secureRandom)
: new DefaultCsrfToken(this.headerName, this.parameterName, createNewToken());
}

@Override
public void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response) {
String tokenValue = (token != null) ? token.getToken() : "";
String tokenValue = getTokenValue(token);
Cookie cookie = new Cookie(this.cookieName, tokenValue);
cookie.setSecure((this.secure != null) ? this.secure : request.isSecure());
cookie.setPath(StringUtils.hasLength(this.cookiePath) ? this.cookiePath : this.getRequestContext(request));
Expand All @@ -91,7 +101,9 @@ public CsrfToken loadToken(HttpServletRequest request) {
if (!StringUtils.hasLength(token)) {
return null;
}
return new DefaultCsrfToken(this.headerName, this.parameterName, token);
return this.isXorRandomSecretEnabled()
? new DefaultCsrfToken(this.headerName, this.parameterName, token, this.secureRandom)
: new DefaultCsrfToken(this.headerName, this.parameterName, token);
}

/**
Expand Down Expand Up @@ -134,6 +146,20 @@ public void setCookieHttpOnly(boolean cookieHttpOnly) {
this.cookieHttpOnly = cookieHttpOnly;
}

/**
* Enables generating random secrets to XOR with the csrf token on each request.
* @param enabled {@code true} sets the {@link SecureRandom} used for generating
* random secrets, {@code false} causes it to be set to null
*/
public void setXorRandomSecretEnabled(boolean enabled) {
if (enabled) {
this.secureRandom = new SecureRandom();
}
else {
this.secureRandom = null;
}
}

private String getRequestContext(HttpServletRequest request) {
String contextPath = request.getContextPath();
return (contextPath.length() > 0) ? contextPath : "/";
Expand All @@ -151,6 +177,43 @@ public static CookieCsrfTokenRepository withHttpOnlyFalse() {
return result;
}

/**
* Factory method to create an instance of {@link CookieCsrfTokenRepository} that has
* {@link #setXorRandomSecretEnabled(boolean)} set to {@code true}.
* @return an instance of {@link CookieCsrfTokenRepository} with
* {@link #setXorRandomSecretEnabled(boolean)} set to {@code true}
*/
public static CookieCsrfTokenRepository withXorRandomSecretEnabled() {
CookieCsrfTokenRepository result = new CookieCsrfTokenRepository();
result.setXorRandomSecretEnabled(true);
return result;
}

/**
* Factory method to create an instance of {@link CookieCsrfTokenRepository} that has
* {@link #setXorRandomSecretEnabled(boolean)} set to {@code true}.
* @param secureRandom the {@link SecureRandom} to use for generating random secrets
* @return an instance of {@link CookieCsrfTokenRepository} with
* {@link #setXorRandomSecretEnabled(boolean)} set to {@code true}
*/
public static CookieCsrfTokenRepository withXorRandomSecretEnabled(SecureRandom secureRandom) {
return new CookieCsrfTokenRepository(secureRandom);
}

private boolean isXorRandomSecretEnabled() {
return (this.secureRandom != null);
}

private String getTokenValue(CsrfToken token) {
if (token == null) {
return "";
}
else if (token instanceof DefaultCsrfToken) {
return ((DefaultCsrfToken) token).getRawToken();
}
return token.getToken();
}

private String createNewToken() {
return UUID.randomUUID().toString();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2021 the original author or authors.
* Copyright 2002-2022 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -17,7 +17,6 @@
package org.springframework.security.web.csrf;

import java.io.IOException;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.HashSet;

Expand All @@ -32,7 +31,6 @@

import org.springframework.core.log.LogMessage;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.crypto.codec.Utf8;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.access.AccessDeniedHandlerImpl;
import org.springframework.security.web.util.UrlUtils;
Expand Down Expand Up @@ -121,7 +119,8 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
if (actualToken == null) {
actualToken = request.getParameter(csrfToken.getParameterName());
}
if (!equalsConstantTime(csrfToken.getToken(), actualToken)) {
// Default matches method uses constant-time comparison
if (!csrfToken.matches(actualToken)) {
this.logger.debug(
LogMessage.of(() -> "Invalid CSRF token found for " + UrlUtils.buildFullRequestUrl(request)));
AccessDeniedException exception = (!missingToken) ? new InvalidCsrfTokenException(csrfToken, actualToken)
Expand Down Expand Up @@ -167,25 +166,6 @@ public void setAccessDeniedHandler(AccessDeniedHandler accessDeniedHandler) {
this.accessDeniedHandler = accessDeniedHandler;
}

/**
* Constant time comparison to prevent against timing attacks.
* @param expected
* @param actual
* @return
*/
private static boolean equalsConstantTime(String expected, String actual) {
if (expected == actual) {
return true;
}
if (expected == null || actual == null) {
return false;
}
// Encode after ensure that the string is not null
byte[] expectedBytes = Utf8.encode(expected);
byte[] actualBytes = Utf8.encode(actual);
return MessageDigest.isEqual(expectedBytes, actualBytes);
}

private static final class DefaultRequiresCsrfMatcher implements RequestMatcher {

private final HashSet<String> allowedMethods = new HashSet<>(Arrays.asList("GET", "HEAD", "TRACE", "OPTIONS"));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2013 the original author or authors.
* Copyright 2002-2022 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -17,6 +17,9 @@
package org.springframework.security.web.csrf;

import java.io.Serializable;
import java.security.MessageDigest;

import org.springframework.security.crypto.codec.Utf8;

/**
* Provides the information about an expected CSRF token.
Expand Down Expand Up @@ -47,4 +50,25 @@ public interface CsrfToken extends Serializable {
*/
String getToken();

/**
* Determine if this token matches with another token. Implementations should use a
* constant time comparison to protect against timing attacks.
* @param token The token to be matched against
* @return true if this instance token matches the token, otherwise false.
* @since 5.7
*/
default boolean matches(String token) {
String expectedToken = getToken();
if (expectedToken == token) {
return true;
}
if (expectedToken == null || token == null) {
return false;
}
// Encode after ensuring that the string is not null
byte[] expectedBytes = Utf8.encode(expectedToken);
byte[] actualBytes = Utf8.encode(token);
return MessageDigest.isEqual(expectedBytes, actualBytes);
}

}
Loading