Skip to content

Create CsrfCustomizer for SPA configuration (#14149) #16966

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

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
Expand Up @@ -19,9 +19,11 @@
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.function.Supplier;

import io.micrometer.observation.ObservationRegistry;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import org.springframework.context.ApplicationContext;
import org.springframework.security.access.AccessDeniedException;
Expand All @@ -34,20 +36,25 @@
import org.springframework.security.web.access.DelegatingAccessDeniedHandler;
import org.springframework.security.web.access.ObservationMarkingAccessDeniedHandler;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfAuthenticationStrategy;
import org.springframework.security.web.csrf.CsrfFilter;
import org.springframework.security.web.csrf.CsrfLogoutHandler;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.csrf.CsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.csrf.CsrfTokenRequestHandler;
import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository;
import org.springframework.security.web.csrf.MissingCsrfTokenException;
import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler;
import org.springframework.security.web.session.InvalidSessionAccessDeniedHandler;
import org.springframework.security.web.session.InvalidSessionStrategy;
import org.springframework.security.web.util.matcher.AndRequestMatcher;
import org.springframework.security.web.util.matcher.NegatedRequestMatcher;
import org.springframework.security.web.util.matcher.OrRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;

/**
* Adds
Expand Down Expand Up @@ -214,6 +221,21 @@ public CsrfConfigurer<H> sessionAuthenticationStrategy(
return this;
}

/**
* <p>
* Sensible CSRF defaults when used in combination with a single page application.
* Creates a cookie-based token repository and a custom request handler to resolve the
* actual token value instead of the encoded token.
* </p>
* @return the {@link CsrfConfigurer} for further customizations
* @since 7.0
*/
public CsrfConfigurer<H> spa() {
this.csrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse();
this.requestHandler = new SpaCsrfTokenRequestHandler();
return this;
}

@SuppressWarnings("unchecked")
@Override
public void configure(H http) {
Expand Down Expand Up @@ -375,4 +397,42 @@ protected IgnoreCsrfProtectionRegistry chainRequestMatchers(List<RequestMatcher>

}

private static class SpaCsrfTokenRequestHandler implements CsrfTokenRequestHandler {

private final CsrfTokenRequestAttributeHandler plain = new CsrfTokenRequestAttributeHandler();

private final CsrfTokenRequestAttributeHandler xor = new XorCsrfTokenRequestAttributeHandler();

SpaCsrfTokenRequestHandler() {
this.xor.setCsrfRequestAttributeName(null);
}

@Override
public void handle(HttpServletRequest request, HttpServletResponse response, Supplier<CsrfToken> csrfToken) {
/*
* Always use XorCsrfTokenRequestAttributeHandler to provide BREACH protection
* of the CsrfToken when it is rendered in the response body.
*/
this.xor.handle(request, response, csrfToken);
}

@Override
public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
String headerValue = request.getHeader(csrfToken.getHeaderName());
/*
* If the request contains a request header, use
* CsrfTokenRequestAttributeHandler to resolve the CsrfToken. This applies
* when a single-page application includes the header value automatically,
* which was obtained via a cookie containing the raw CsrfToken.
*
* In all other cases (e.g. if the request contains a request parameter), use
* XorCsrfTokenRequestAttributeHandler to resolve the CsrfToken. This applies
* when a server-side rendered form includes the _csrf request parameter as a
* hidden input.
*/
return (StringUtils.hasText(headerValue) ? this.plain : this.xor).resolveCsrfTokenValue(request, csrfToken);
}

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
Expand Down Expand Up @@ -613,6 +614,37 @@ public void getWhenHttpBasicAndCookieCsrfTokenRepositorySetAndNoExistingCookieTh
assertThat(cookies).isEmpty();
}

@Test
public void spaConfigForbidden() throws Exception {
this.spring.register(CsrfSpaConfig.class, AllowHttpMethodsFirewallConfig.class, BasicController.class)
.autowire();
this.mvc.perform(post("/")).andExpect(status().isForbidden());
}

@Test
public void spaConfigOk() throws Exception {
this.spring.register(CsrfSpaConfig.class, AllowHttpMethodsFirewallConfig.class, BasicController.class)
.autowire();
this.mvc.perform(post("/").with(csrf())).andExpect(status().isOk());
}

@Test
public void spaConfigDoubleSubmit() throws Exception {
this.spring.register(CsrfSpaConfig.class, AllowHttpMethodsFirewallConfig.class, BasicController.class)
.autowire();
var token = this.mvc.perform(post("/"))
.andExpect(status().isForbidden())
.andExpect(cookie().exists("XSRF-TOKEN"))
.andReturn()
.getResponse()
.getCookie("XSRF-TOKEN");

this.mvc
.perform(post("/").header("X-XSRF-TOKEN", token.getValue())
.cookie(new Cookie("XSRF-TOKEN", token.getValue())))
.andExpect(status().isOk());
}

@Configuration
static class AllowHttpMethodsFirewallConfig {

Expand Down Expand Up @@ -1006,6 +1038,18 @@ void configure(AuthenticationManagerBuilder auth) throws Exception {

}

@Configuration
@EnableWebSecurity
static class CsrfSpaConfig {

@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(CsrfConfigurer::spa);
return http.build();
}

}

@Configuration
@EnableWebSecurity
static class HttpBasicCsrfTokenRequestHandlerConfig {
Expand Down
96 changes: 5 additions & 91 deletions docs/modules/ROOT/pages/servlet/exploits/csrf.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -787,48 +787,10 @@ public class SecurityConfig {
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// ...
.csrf((csrf) -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) // <1>
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()) // <2>
);
.csrf((csrf) -> csrf.spa());
return http.build();
}
}

final class SpaCsrfTokenRequestHandler implements CsrfTokenRequestHandler {
private final CsrfTokenRequestHandler plain = new CsrfTokenRequestAttributeHandler();
private final CsrfTokenRequestHandler xor = new XorCsrfTokenRequestAttributeHandler();

@Override
public void handle(HttpServletRequest request, HttpServletResponse response, Supplier<CsrfToken> csrfToken) {
/*
* Always use XorCsrfTokenRequestAttributeHandler to provide BREACH protection of
* the CsrfToken when it is rendered in the response body.
*/
this.xor.handle(request, response, csrfToken);
/*
* Render the token value to a cookie by causing the deferred token to be loaded.
*/
csrfToken.get();
}

@Override
public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
String headerValue = request.getHeader(csrfToken.getHeaderName());
/*
* If the request contains a request header, use CsrfTokenRequestAttributeHandler
* to resolve the CsrfToken. This applies when a single-page application includes
* the header value automatically, which was obtained via a cookie containing the
* raw CsrfToken.
*
* In all other cases (e.g. if the request contains a request parameter), use
* XorCsrfTokenRequestAttributeHandler to resolve the CsrfToken. This applies
* when a server-side rendered form includes the _csrf request parameter as a
* hidden input.
*/
return (StringUtils.hasText(headerValue) ? this.plain : this.xor).resolveCsrfTokenValue(request, csrfToken);
}
}
----

Kotlin::
Expand All @@ -846,51 +808,12 @@ class SecurityConfig {
http {
// ...
csrf {
csrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse() // <1>
csrfTokenRequestHandler = SpaCsrfTokenRequestHandler() // <2>
spa()
}
}
return http.build()
}
}

class SpaCsrfTokenRequestHandler : CsrfTokenRequestHandler {
private val plain: CsrfTokenRequestHandler = CsrfTokenRequestAttributeHandler()
private val xor: CsrfTokenRequestHandler = XorCsrfTokenRequestAttributeHandler()

override fun handle(request: HttpServletRequest, response: HttpServletResponse, csrfToken: Supplier<CsrfToken>) {
/*
* Always use XorCsrfTokenRequestAttributeHandler to provide BREACH protection of
* the CsrfToken when it is rendered in the response body.
*/
xor.handle(request, response, csrfToken)
/*
* Render the token value to a cookie by causing the deferred token to be loaded.
*/
csrfToken.get()
}

override fun resolveCsrfTokenValue(request: HttpServletRequest, csrfToken: CsrfToken): String? {
val headerValue = request.getHeader(csrfToken.headerName)
/*
* If the request contains a request header, use CsrfTokenRequestAttributeHandler
* to resolve the CsrfToken. This applies when a single-page application includes
* the header value automatically, which was obtained via a cookie containing the
* raw CsrfToken.
*/
return if (StringUtils.hasText(headerValue)) {
plain
} else {
/*
* In all other cases (e.g. if the request contains a request parameter), use
* XorCsrfTokenRequestAttributeHandler to resolve the CsrfToken. This applies
* when a server-side rendered form includes the _csrf request parameter as a
* hidden input.
*/
xor
}.resolveCsrfTokenValue(request, csrfToken)
}
}
----

XML::
Expand All @@ -899,22 +822,13 @@ XML::
----
<http>
<!-- ... -->
<csrf
token-repository-ref="tokenRepository" <1>
request-handler-ref="requestHandler"/> <2>
<csrf>
<spa />
</csrf>
</http>
<b:bean id="tokenRepository"
class="org.springframework.security.web.csrf.CookieCsrfTokenRepository"
p:cookieHttpOnly="false"/>
<b:bean id="requestHandler"
class="example.SpaCsrfTokenRequestHandler"/>
----
======

<1> Configure `CookieCsrfTokenRepository` with `HttpOnly` set to `false` so the cookie can be read by the JavaScript application.
<2> Configure a custom `CsrfTokenRequestHandler` that resolves the CSRF token based on whether it is an HTTP request header (`X-XSRF-TOKEN`) or request parameter (`_csrf`).
This implementation also causes the deferred `CsrfToken` to be loaded on every request, which will return a new cookie if needed.

[[csrf-integration-javascript-mpa]]
==== Multi-Page Applications

Expand Down
Loading