Add the cors-csrf module
This commit is contained in:
114
cors-csrf/docs/06-csrf-for-spas.md
Normal file
114
cors-csrf/docs/06-csrf-for-spas.md
Normal 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 — 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`** — 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](05-the-error-dispatch.md) · Next: [7. SameSite](07-samesite.md)*
|
||||
Reference in New Issue
Block a user