1
0

Add the cors-csrf module

This commit is contained in:
2026-08-28 09:33:22 +05:30
parent 73ab67b171
commit cad813e1ae
49 changed files with 3338 additions and 13 deletions

View File

@@ -0,0 +1,114 @@
# 6. CSRF for SPAs, and what `spa()` actually assigns
*Prev: [5. The /error dispatch](05-the-error-dispatch.md) · Next: [7. SameSite](07-samesite.md)*
## The recipe that stopped working in 6.0
```java
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`](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:
```java
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:
```java
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 &mdash; 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`](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:
```java
.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`](output/11-spa-ordering.txt) shows `XSRF-TOKEN` coming back
instead. Tracked as [spring-security#18718](https://github.com/spring-projects/spring-security/issues/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`** &mdash; 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 &mdash; including a
`HttpOnly` refresh cookie &mdash; then yes, and `spa()` is the shortest correct configuration.
---
*Prev: [5. The /error dispatch](05-the-error-dispatch.md) &middot; Next: [7. SameSite](07-samesite.md)*