Skip to content

Commit 1a4de49

Browse files
felhagjzheaux
authored andcommitted
Create CsrfCustomizer for SPA configuration
Closes gh-14149 Signed-off-by: Felix Hagemans <[email protected]>
1 parent 52394c1 commit 1a4de49

File tree

3 files changed

+109
-91
lines changed

3 files changed

+109
-91
lines changed

config/src/main/java/org/springframework/security/config/annotation/web/configurers/CsrfConfigurer.java

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@
1919
import java.util.ArrayList;
2020
import java.util.LinkedHashMap;
2121
import java.util.List;
22+
import java.util.function.Supplier;
2223

2324
import io.micrometer.observation.ObservationRegistry;
2425
import jakarta.servlet.http.HttpServletRequest;
26+
import jakarta.servlet.http.HttpServletResponse;
2527

2628
import org.springframework.context.ApplicationContext;
2729
import org.springframework.security.access.AccessDeniedException;
@@ -34,20 +36,25 @@
3436
import org.springframework.security.web.access.DelegatingAccessDeniedHandler;
3537
import org.springframework.security.web.access.ObservationMarkingAccessDeniedHandler;
3638
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
39+
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
3740
import org.springframework.security.web.csrf.CsrfAuthenticationStrategy;
3841
import org.springframework.security.web.csrf.CsrfFilter;
3942
import org.springframework.security.web.csrf.CsrfLogoutHandler;
43+
import org.springframework.security.web.csrf.CsrfToken;
4044
import org.springframework.security.web.csrf.CsrfTokenRepository;
45+
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
4146
import org.springframework.security.web.csrf.CsrfTokenRequestHandler;
4247
import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository;
4348
import org.springframework.security.web.csrf.MissingCsrfTokenException;
49+
import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler;
4450
import org.springframework.security.web.session.InvalidSessionAccessDeniedHandler;
4551
import org.springframework.security.web.session.InvalidSessionStrategy;
4652
import org.springframework.security.web.util.matcher.AndRequestMatcher;
4753
import org.springframework.security.web.util.matcher.NegatedRequestMatcher;
4854
import org.springframework.security.web.util.matcher.OrRequestMatcher;
4955
import org.springframework.security.web.util.matcher.RequestMatcher;
5056
import org.springframework.util.Assert;
57+
import org.springframework.util.StringUtils;
5158

5259
/**
5360
* Adds
@@ -214,6 +221,21 @@ public CsrfConfigurer<H> sessionAuthenticationStrategy(
214221
return this;
215222
}
216223

224+
/**
225+
* <p>
226+
* Sensible CSRF defaults when used in combination with a single page application.
227+
* Creates a cookie-based token repository and a custom request handler to resolve the
228+
* actual token value instead of the encoded token.
229+
* </p>
230+
* @return the {@link CsrfConfigurer} for further customizations
231+
* @since 7.0
232+
*/
233+
public CsrfConfigurer<H> spa() {
234+
this.csrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse();
235+
this.requestHandler = new SpaCsrfTokenRequestHandler();
236+
return this;
237+
}
238+
217239
@SuppressWarnings("unchecked")
218240
@Override
219241
public void configure(H http) {
@@ -375,4 +397,42 @@ protected IgnoreCsrfProtectionRegistry chainRequestMatchers(List<RequestMatcher>
375397

376398
}
377399

400+
private static class SpaCsrfTokenRequestHandler implements CsrfTokenRequestHandler {
401+
402+
private final CsrfTokenRequestAttributeHandler plain = new CsrfTokenRequestAttributeHandler();
403+
404+
private final CsrfTokenRequestAttributeHandler xor = new XorCsrfTokenRequestAttributeHandler();
405+
406+
SpaCsrfTokenRequestHandler() {
407+
this.xor.setCsrfRequestAttributeName(null);
408+
}
409+
410+
@Override
411+
public void handle(HttpServletRequest request, HttpServletResponse response, Supplier<CsrfToken> csrfToken) {
412+
/*
413+
* Always use XorCsrfTokenRequestAttributeHandler to provide BREACH protection
414+
* of the CsrfToken when it is rendered in the response body.
415+
*/
416+
this.xor.handle(request, response, csrfToken);
417+
}
418+
419+
@Override
420+
public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
421+
String headerValue = request.getHeader(csrfToken.getHeaderName());
422+
/*
423+
* If the request contains a request header, use
424+
* CsrfTokenRequestAttributeHandler to resolve the CsrfToken. This applies
425+
* when a single-page application includes the header value automatically,
426+
* which was obtained via a cookie containing the raw CsrfToken.
427+
*
428+
* In all other cases (e.g. if the request contains a request parameter), use
429+
* XorCsrfTokenRequestAttributeHandler to resolve the CsrfToken. This applies
430+
* when a server-side rendered form includes the _csrf request parameter as a
431+
* hidden input.
432+
*/
433+
return (StringUtils.hasText(headerValue) ? this.plain : this.xor).resolveCsrfTokenValue(request, csrfToken);
434+
}
435+
436+
}
437+
378438
}

config/src/test/java/org/springframework/security/config/annotation/web/configurers/CsrfConfigurerTests.java

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@
9393
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
9494
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request;
9595
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
96+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie;
9697
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
9798
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
9899

@@ -613,6 +614,37 @@ public void getWhenHttpBasicAndCookieCsrfTokenRepositorySetAndNoExistingCookieTh
613614
assertThat(cookies).isEmpty();
614615
}
615616

617+
@Test
618+
public void spaConfigForbidden() throws Exception {
619+
this.spring.register(CsrfSpaConfig.class, AllowHttpMethodsFirewallConfig.class, BasicController.class)
620+
.autowire();
621+
this.mvc.perform(post("/")).andExpect(status().isForbidden());
622+
}
623+
624+
@Test
625+
public void spaConfigOk() throws Exception {
626+
this.spring.register(CsrfSpaConfig.class, AllowHttpMethodsFirewallConfig.class, BasicController.class)
627+
.autowire();
628+
this.mvc.perform(post("/").with(csrf())).andExpect(status().isOk());
629+
}
630+
631+
@Test
632+
public void spaConfigDoubleSubmit() throws Exception {
633+
this.spring.register(CsrfSpaConfig.class, AllowHttpMethodsFirewallConfig.class, BasicController.class)
634+
.autowire();
635+
var token = this.mvc.perform(post("/"))
636+
.andExpect(status().isForbidden())
637+
.andExpect(cookie().exists("XSRF-TOKEN"))
638+
.andReturn()
639+
.getResponse()
640+
.getCookie("XSRF-TOKEN");
641+
642+
this.mvc
643+
.perform(post("/").header("X-XSRF-TOKEN", token.getValue())
644+
.cookie(new Cookie("XSRF-TOKEN", token.getValue())))
645+
.andExpect(status().isOk());
646+
}
647+
616648
@Configuration
617649
static class AllowHttpMethodsFirewallConfig {
618650

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

10071039
}
10081040

1041+
@Configuration
1042+
@EnableWebSecurity
1043+
static class CsrfSpaConfig {
1044+
1045+
@Bean
1046+
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
1047+
http.csrf(CsrfConfigurer::spa);
1048+
return http.build();
1049+
}
1050+
1051+
}
1052+
10091053
@Configuration
10101054
@EnableWebSecurity
10111055
static class HttpBasicCsrfTokenRequestHandlerConfig {

docs/modules/ROOT/pages/servlet/exploits/csrf.adoc

Lines changed: 5 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -787,48 +787,10 @@ public class SecurityConfig {
787787
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
788788
http
789789
// ...
790-
.csrf((csrf) -> csrf
791-
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) // <1>
792-
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()) // <2>
793-
);
790+
.csrf((csrf) -> csrf.spa());
794791
return http.build();
795792
}
796793
}
797-
798-
final class SpaCsrfTokenRequestHandler implements CsrfTokenRequestHandler {
799-
private final CsrfTokenRequestHandler plain = new CsrfTokenRequestAttributeHandler();
800-
private final CsrfTokenRequestHandler xor = new XorCsrfTokenRequestAttributeHandler();
801-
802-
@Override
803-
public void handle(HttpServletRequest request, HttpServletResponse response, Supplier<CsrfToken> csrfToken) {
804-
/*
805-
* Always use XorCsrfTokenRequestAttributeHandler to provide BREACH protection of
806-
* the CsrfToken when it is rendered in the response body.
807-
*/
808-
this.xor.handle(request, response, csrfToken);
809-
/*
810-
* Render the token value to a cookie by causing the deferred token to be loaded.
811-
*/
812-
csrfToken.get();
813-
}
814-
815-
@Override
816-
public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
817-
String headerValue = request.getHeader(csrfToken.getHeaderName());
818-
/*
819-
* If the request contains a request header, use CsrfTokenRequestAttributeHandler
820-
* to resolve the CsrfToken. This applies when a single-page application includes
821-
* the header value automatically, which was obtained via a cookie containing the
822-
* raw CsrfToken.
823-
*
824-
* In all other cases (e.g. if the request contains a request parameter), use
825-
* XorCsrfTokenRequestAttributeHandler to resolve the CsrfToken. This applies
826-
* when a server-side rendered form includes the _csrf request parameter as a
827-
* hidden input.
828-
*/
829-
return (StringUtils.hasText(headerValue) ? this.plain : this.xor).resolveCsrfTokenValue(request, csrfToken);
830-
}
831-
}
832794
----
833795
834796
Kotlin::
@@ -846,51 +808,12 @@ class SecurityConfig {
846808
http {
847809
// ...
848810
csrf {
849-
csrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse() // <1>
850-
csrfTokenRequestHandler = SpaCsrfTokenRequestHandler() // <2>
811+
spa()
851812
}
852813
}
853814
return http.build()
854815
}
855816
}
856-
857-
class SpaCsrfTokenRequestHandler : CsrfTokenRequestHandler {
858-
private val plain: CsrfTokenRequestHandler = CsrfTokenRequestAttributeHandler()
859-
private val xor: CsrfTokenRequestHandler = XorCsrfTokenRequestAttributeHandler()
860-
861-
override fun handle(request: HttpServletRequest, response: HttpServletResponse, csrfToken: Supplier<CsrfToken>) {
862-
/*
863-
* Always use XorCsrfTokenRequestAttributeHandler to provide BREACH protection of
864-
* the CsrfToken when it is rendered in the response body.
865-
*/
866-
xor.handle(request, response, csrfToken)
867-
/*
868-
* Render the token value to a cookie by causing the deferred token to be loaded.
869-
*/
870-
csrfToken.get()
871-
}
872-
873-
override fun resolveCsrfTokenValue(request: HttpServletRequest, csrfToken: CsrfToken): String? {
874-
val headerValue = request.getHeader(csrfToken.headerName)
875-
/*
876-
* If the request contains a request header, use CsrfTokenRequestAttributeHandler
877-
* to resolve the CsrfToken. This applies when a single-page application includes
878-
* the header value automatically, which was obtained via a cookie containing the
879-
* raw CsrfToken.
880-
*/
881-
return if (StringUtils.hasText(headerValue)) {
882-
plain
883-
} else {
884-
/*
885-
* In all other cases (e.g. if the request contains a request parameter), use
886-
* XorCsrfTokenRequestAttributeHandler to resolve the CsrfToken. This applies
887-
* when a server-side rendered form includes the _csrf request parameter as a
888-
* hidden input.
889-
*/
890-
xor
891-
}.resolveCsrfTokenValue(request, csrfToken)
892-
}
893-
}
894817
----
895818
896819
XML::
@@ -899,22 +822,13 @@ XML::
899822
----
900823
<http>
901824
<!-- ... -->
902-
<csrf
903-
token-repository-ref="tokenRepository" <1>
904-
request-handler-ref="requestHandler"/> <2>
825+
<csrf>
826+
<spa />
827+
</csrf>
905828
</http>
906-
<b:bean id="tokenRepository"
907-
class="org.springframework.security.web.csrf.CookieCsrfTokenRepository"
908-
p:cookieHttpOnly="false"/>
909-
<b:bean id="requestHandler"
910-
class="example.SpaCsrfTokenRequestHandler"/>
911829
----
912830
======
913831

914-
<1> Configure `CookieCsrfTokenRepository` with `HttpOnly` set to `false` so the cookie can be read by the JavaScript application.
915-
<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`).
916-
This implementation also causes the deferred `CsrfToken` to be loaded on every request, which will return a new cookie if needed.
917-
918832
[[csrf-integration-javascript-mpa]]
919833
==== Multi-Page Applications
920834

0 commit comments

Comments
 (0)