5.0 KiB
6. CSRF for SPAs, and what spa() actually assigns
Prev: 5. The /error dispatch · Next: 7. SameSite
The recipe that stopped working in 6.0
http.csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
Every SPA tutorial written before Spring Security 6 ends with that line. Three separate things go
wrong with it now, and docs/output/08-csrf-naive.txt walks through
all three in one transcript.
1. The bootstrap GET sets no cookie. Since 6.0 the token is deferred: CsrfFilter puts a
Supplier<CsrfToken> in a request attribute and only materialises it if something dereferences
it. A GET on a JSON endpoint dereferences nothing. So the SPA starts up, sees no XSRF-TOKEN
cookie, and its first mutating request has nothing to send.
2. Sending the raw cookie value fails. The default handler is
XorCsrfTokenRequestAttributeHandler, added as a BREACH mitigation. It expects the value in the
header to be XOR-masked. CookieCsrfTokenRepository writes the raw token into the cookie.
So the SPA reads a raw value, sends a raw value, and the handler tries to unmask it. 403.
3. The cookie is not sent cross-site anyway. Chapter 7.
What spa() is
Spring Security 7.0 added CsrfConfigurer.spa(). Its entire bytecode:
0: aload_0
1: invokestatic // CookieCsrfTokenRepository.withHttpOnlyFalse()
4: putfield // Field csrfTokenRepository
7: aload_0
8: new // class CsrfConfigurer$SpaCsrfTokenRequestHandler
12: invokespecial // <init>
15: putfield // Field requestHandler
18: aload_0
19: areturn
Two unconditional assignments. SpaCsrfTokenRequestHandler holds two delegates:
private final CsrfTokenRequestAttributeHandler plain = new CsrfTokenRequestAttributeHandler();
private final CsrfTokenRequestAttributeHandler xor = new XorCsrfTokenRequestAttributeHandler();
// constructor: xor.setCsrfRequestAttributeName(null);
handle(..) always delegates to xor. resolveCsrfTokenValue(..) picks plain when the
request carries the header and xor otherwise:
String headerValue = request.getHeader(csrfToken.getHeaderName());
return (StringUtils.hasText(headerValue) ? this.plain : this.xor)
.resolveCsrfTokenValue(request, csrfToken);
So a SPA reading the cookie and echoing it in X-XSRF-TOKEN compares raw against raw and
succeeds, while a <form> post keeps the BREACH masking on the hidden field. Both work, from
one configuration.
The part nobody documents: why the cookie now appears on the GET
That xor.setCsrfRequestAttributeName(null) looks like a detail. It is the fix for problem 1.
CsrfTokenRequestAttributeHandler.handle wraps the supplier in a SupplierCsrfToken and sets
two request attributes. The key for the second one is the configured attribute name — or,
when that is null, csrfToken.getParameterName(). Calling getParameterName() on a
SupplierCsrfToken dereferences the supplier. The token is generated, the repository saves
it, and the Set-Cookie header goes out.
The eager rendering is a side effect of needing a string for a map key. It is real, it is
load-bearing, and docs/output/10-csrf-spa.txt shows the cookie
arriving on the bootstrap GET where 08 showed nothing.
spa() discards what you configured before it
Because the two assignments are unconditional:
.csrf(csrf -> csrf.csrfTokenRepository(myRepository).spa()) // myRepository is gone
.csrf(csrf -> csrf.spa().csrfTokenRepository(myRepository)) // this one wins
The spaorder profile asks for a cookie named MY-CSRF and a header named X-CSRF-TOKEN;
docs/output/11-spa-ordering.txt shows XSRF-TOKEN coming back
instead. Tracked as spring-security#18718.
A CSRF rejection does not look like a CSRF rejection
CsrfFilter logs, at DEBUG:
o.s.security.web.csrf.CsrfFilter : Invalid CSRF token found for http://localhost:8080/api/data
and hands off to its AccessDeniedHandler, which calls sendError(403). What the client
receives, in a Basic-authenticated API with no /error chain, is 401 with
WWW-Authenticate: Basic — chapter 5. Every hour spent checking credentials after a
403-that-is-a-401 is spent on the wrong thing.
Turn on logging.level.org.springframework.security.web.csrf: DEBUG before anything else.
Do you need CSRF at all?
If the API authenticates with a Bearer token held in memory and never with a cookie, then no:
there is no ambient credential for a third-party page to ride on, and csrf.disable() is
correct rather than lazy. If any part of the session lives in a cookie — including a
HttpOnly refresh cookie — then yes, and spa() is the shortest correct configuration.
Prev: 5. The /error dispatch · Next: 7. SameSite