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,82 @@
# 1. Two layers, one word
*Next: [2. Who resolves the CorsConfigurationSource](02-who-resolves-the-source.md)*
A Spring Boot application can be told about CORS in two entirely separate places, and the two
places do not talk to each other unless you make them.
**The MVC layer.** `WebMvcConfigurer.addCorsMappings(..)` and `@CrossOrigin` register a
`CorsConfiguration` with Spring MVC's handler mappings. It is consulted inside
`DispatcherServlet`, when the request is being matched to a handler method.
**The security layer.** `HttpSecurity.cors(..)` puts a `org.springframework.web.filter.CorsFilter`
into the security filter chain. It runs at order **1000** — between `HeaderWriterFilter`
(900) and `CsrfFilter` (1100), and a long way above `AuthorizationFilter` (4200).
The security filter chain runs to completion before `DispatcherServlet` is ever entered. So if
the security chain rejects a request, MVC's CORS configuration is not merely ignored: the code
that reads it never executes.
## Why that specifically breaks preflights
A CORS preflight is not a special protocol. It is an ordinary `OPTIONS` request carrying two
headers:
```
OPTIONS /api/data HTTP/1.1
Origin: https://spa.example.com
Access-Control-Request-Method: POST
```
The browser sends it **without credentials** — no cookies, no `Authorization` header, by
design. Against `anyRequest().authenticated()` that request is anonymous, and anonymous requests
are denied. The rejection happens at order 4200, three thousand two hundred slots before
`DispatcherServlet` and about four thousand before your `addCorsMappings` call matters.
[`docs/output/01-mvc-only.txt`](output/01-mvc-only.txt) is that state: an eleven-filter chain
with no `CorsFilter` in it, and a preflight answered `401`.
## The fix is one line, and it is not on the MVC layer
```java
http.cors(Customizer.withDefaults())
```
That is [`MvcBridgeSecurityConfig`](../src/main/java/com/ankurm/cors/config/MvcBridgeSecurityConfig.java),
and [`docs/output/02-mvc-bridge.txt`](output/02-mvc-bridge.txt) is the identical application
answering `200`. The MVC configuration was fine all along. Nothing was reading it.
`CorsFilter` short-circuits every preflight it sees:
```java
boolean isValid = this.processor.processRequest(corsConfiguration, request, response);
if (!isValid || CorsUtils.isPreFlightRequest(request)) {
return; // the chain stops here
}
filterChain.doFilter(request, response);
```
Read that `if` carefully, because chapter 2 turns on it: the filter returns on **every**
preflight, whether or not it found a configuration to apply.
## One thing that changes when you move the configuration
Moving CORS from `addCorsMappings` to a `CorsConfigurationSource` bean is not a pure
relocation. Compare the two transcripts:
```
02-mvc-bridge.txt Access-Control-Max-Age: 1800
03-security-source.txt (nothing)
```
`CorsRegistration` — the builder behind `addCorsMappings` — defaults `maxAge` to
1800 seconds. A bare `CorsConfiguration` leaves it `null`, and a preflight response with no
`Access-Control-Max-Age` is not cached, so the browser preflights **every single cross-origin
call**. Two round trips instead of one, forever, with nothing in any log to suggest it.
```java
configuration.setMaxAge(1800L);
```
---
*Next: [2. Who resolves the CorsConfigurationSource](02-who-resolves-the-source.md)*

View File

@@ -0,0 +1,99 @@
# 2. Who resolves the `CorsConfigurationSource`
*Prev: [1. Two layers, one word](01-two-layers.md) · Next: [3. The three identical 403s](03-three-identical-403s.md)*
There are two lookups involved in getting a CORS configuration into the filter chain, and they
disagree about what they are looking for. One searches by **type**. The other searches by
**name**.
## Lookup 1: should the CORS configurer run at all?
`HttpSecurityConfiguration.applyCorsIfAvailable(HttpSecurity)`, disassembled from
`spring-security-config` 7.1.1:
```
4: ldc // class org/springframework/web/cors/UrlBasedCorsConfigurationSource
6: invokeinterface // ApplicationContext.getBeanNamesForType:(Ljava/lang/Class;)[Ljava/lang/String;
11: arraylength
12: ifle 23
19: invokevirtual // HttpSecurity.cors:(Customizer)HttpSecurity
23: return
```
By type, and the test is `ifle` — "branch if less than or equal to zero". One bean is
enough. So is five.
> The reference documentation says: *"If you have more than one `CorsConfigurationSource` bean,
> Spring Security won't automatically configure CORS support for you, because it cannot decide
> which one to use."* That is not what 7.1.1 does.
> [`docs/output/06-two-sources.txt`](output/06-two-sources.txt) has two such beans, CORS
> configured, and one of them serving traffic.
## Lookup 2: which source does the configurer use?
`CorsConfigurer.getCorsConfigurationSource(ApplicationContext)`:
```
7: ldc // String corsConfigurationSource
9: invokeinterface // ApplicationContext.containsBeanDefinition:(Ljava/lang/String;)Z
20: ldc // String corsConfigurationSource
24: invokeinterface // ApplicationContext.getBean:(String,Class)Object
34: invokestatic // MvcCorsFilter.getMvcCorsConfigurationSource:(ApplicationContext)CorsConfigurationSource
```
By name. The literal string `corsConfigurationSource`. If no bean definition carries that name,
it falls through to Spring MVC's registrations. (There is a similar name check first, for a
`CorsFilter` bean named `corsFilter`.)
## The gap between them
Name a `UrlBasedCorsConfigurationSource` bean anything other than `corsConfigurationSource` and
you land between the two lookups: CORS is switched **on** by the type lookup, and the
configuration you wrote is **ignored** by the name lookup.
That is the `misnamed` profile, and it produces the worst diagnostic in this whole subject:
```
HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
...
```
Two hundred. No `Access-Control-Allow-Origin`. The browser blocks the request and reports a CORS
error; your access log shows a successful `OPTIONS`; nothing anywhere is red.
Why 200 rather than the 401 from chapter 1? Because `CorsFilter` is now in the chain, and its
`if (!isValid || isPreFlightRequest(request)) return;` fires on the second clause. The request
never reaches `AuthorizationFilter`. The only trace is one DEBUG line:
```
o.s.web.cors.DefaultCorsProcessor : Skip: no CORS configuration has been provided
```
Full transcript: [`docs/output/05-misnamed-bean.txt`](output/05-misnamed-bean.txt).
## What is in the context that you did not put there
`/diag/cors-sources` on a stock Boot web application:
```json
{ "corsConfigurationSourceBeans": { "mvcHandlerMappingIntrospector": "HandlerMappingIntrospector" } }
```
`HandlerMappingIntrospector` implements `CorsConfigurationSource`. It is always there, it is not
a `UrlBasedCorsConfigurationSource`, and it is the object the MVC fallback returns. That is why
the fallback path never throws in a normal application — and why the failure is silent
rather than loud.
## Rules that follow
- Name the bean `corsConfigurationSource`. Exactly that.
- If you want several, pass them per chain with `.cors(c -> c.configurationSource(..))`, which
bypasses both lookups.
- `NoSuchBeanDefinitionException: Failed to find a bean that implements
\`CorsConfigurationSource\`` names three fixes and does not mention the fourth one, which is
usually the right one: rename your bean.
---
*Prev: [1. Two layers, one word](01-two-layers.md) · Next: [3. The three identical 403s](03-three-identical-403s.md)*

View File

@@ -0,0 +1,79 @@
# 3. The three identical 403s
*Prev: [2. Who resolves the CorsConfigurationSource](02-who-resolves-the-source.md) · Next: [4. Preflight handlers](04-preflight-handlers.md)*
`DefaultCorsProcessor` runs three checks on a preflight, in order: origin, method, request
headers. All three failures produce the same thing.
```
HTTP/1.1 403
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Invalid CORS request
```
Same status, same body, byte for byte, no `Access-Control-*` header to distinguish them. The
assertion in `CorsContractTests.threeRejectionsLookIdentical` compares the three bodies for
equality, so if a future version starts distinguishing them, that test fails.
The only place the difference exists is a DEBUG log line, and the three are worth memorising
because they are the fastest CORS diagnosis available:
```
o.s.web.cors.DefaultCorsProcessor : Reject: 'https://evil.example.com' origin is not allowed
o.s.web.cors.DefaultCorsProcessor : Reject: HTTP 'DELETE' is not allowed
o.s.web.cors.DefaultCorsProcessor : Reject: headers '[authorization]' are not allowed
```
Turn them on with:
```yaml
logging.level.org.springframework.web.cors: DEBUG
```
The complete set of messages, read out of the class's constant pool, is five:
| Message | Meaning |
|---|---|
| `Skip: no CORS configuration has been provided` | The source returned `null` for this path — chapter 2 |
| `Skip: response already contains "Access-Control-Allow-Origin"` | Something upstream already handled it |
| `Reject: origin is malformed` | The `Origin` header did not parse |
| `Reject: '…' origin is not allowed` | |
| `Reject: HTTP '…' is not allowed` | |
| `Reject: headers '[…]' are not allowed` | |
## Status codes, and what each one means
Collecting the states this module reproduces:
| What you see | What it means |
|---|---|
| `401`/`403`, no `Access-Control-*` at all | No `CorsFilter` in the chain. The preflight was judged by authorization — chapter 1 |
| `200`, no `Access-Control-*` | `CorsFilter` is present and found no configuration for this path — chapter 2 |
| `403`, `Invalid CORS request` | `CorsFilter` is present and rejected origin, method or headers — this chapter |
| `200` with `Access-Control-Allow-Origin` | It worked |
| `404`, no `Access-Control-*` | The path is outside the pattern you registered. Common with `/api/**` versus a mis-typed URL |
The browser reports the same "blocked by CORS policy" for the first four rows. Two of them are
not CORS problems.
## What a **simple** request does
Only preflighted requests get intercepted. A simple `GET` runs the whole chain, so an
unauthenticated one returns 401 — **carrying** the CORS header, because `CorsFilter` at
1000 already wrote it before `AuthorizationFilter` at 4200 rejected the request:
```
HTTP/1.1 401
Access-Control-Allow-Origin: https://spa.example.com
Access-Control-Allow-Credentials: true
```
That is the good case: the SPA's `fetch` resolves and the code can read `response.status`. It is
also the reason "my POST fails but my GET returns a readable 401" is a coherent bug report and
not a contradiction.
---
*Prev: [2. Who resolves the CorsConfigurationSource](02-who-resolves-the-source.md) · Next: [4. Preflight handlers](04-preflight-handlers.md)*

View File

@@ -0,0 +1,73 @@
# 4. `PreFlightRequestHandler`, and the wildcard that is not allowed
*Prev: [3. The three identical 403s](03-three-identical-403s.md) · Next: [5. The /error dispatch](05-the-error-dispatch.md)*
## `preFlightRequestHandler`
`CorsConfigurer` in 7.1.1 has a second setter beside `configurationSource`:
```java
public CorsConfigurer<H> preFlightRequestHandler(PreFlightRequestHandler handler);
```
When one is selected, Spring Security registers Spring Framework's `PreFlightRequestFilter`
**before** `CorsFilter` in the chain &mdash; `addFilterBefore(.., CorsFilter.class)`, which lands
it at 999. It is for applications that answer preflights from their own routing rather than from
a `CorsConfiguration`, and it is the hook `WebFlux`-style functional routing and gateway-shaped
applications want.
The handler is picked up either from the `preFlightRequestHandler(..)` call or from a
`PreFlightRequestHandler` bean, and only when no `CorsConfigurationSource` or `CorsFilter` was
chosen for that chain. Configuring both raises, at startup:
```
java.lang.IllegalStateException: Cannot configure both a CorsConfigurationSource and a
PreFlightRequestHandler on CorsConfigurer
```
That string is in `CorsConfigurer.configure`'s constant pool; it is a hard failure, not a
warning.
## `allowedOrigins("*")` with `allowCredentials(true)`
The Fetch standard forbids answering a credentialed request with
`Access-Control-Allow-Origin: *`. Spring enforces it &mdash; but not where you would expect.
The configuration builds. The context starts. The check happens on the first request, inside
`CorsConfiguration.validateAllowCredentials`, reached from `checkOrigin`:
```
java.lang.IllegalArgumentException: When allowCredentials is true, allowedOrigins cannot contain
the special value "*" since that cannot be set on the "Access-Control-Allow-Origin" response
header. To allow credentials to a set of origins, list them explicitly or consider using
"allowedOriginPatterns" instead.
at org.springframework.web.cors.CorsConfiguration.validateAllowCredentials(CorsConfiguration.java:552)
at org.springframework.web.cors.CorsConfiguration.checkOrigin(CorsConfiguration.java:678)
at org.springframework.web.cors.DefaultCorsProcessor.checkOrigin(DefaultCorsProcessor.java:193)
```
And here is the part worth knowing: **the client does not get a 500.** It gets a `401`.
[`docs/output/07-wildcard-credentials.txt`](output/07-wildcard-credentials.txt) shows a request
with entirely correct Basic credentials answered `401 WWW-Authenticate: Basic`. Chapter 5 is why.
The fix is `setAllowedOriginPatterns(..)`, which echoes the request's own origin back instead of
a literal asterisk, and is therefore legal with credentials:
```java
configuration.setAllowedOriginPatterns(List.of("https://*.example.com"));
configuration.setAllowCredentials(true);
```
`allowedHeaders("*")` and `allowedMethods("*")` are unaffected &mdash; the prohibition is
specific to the origin, because that is the one that gets reflected into a header the browser
uses to decide whether the caller may read a credentialed response.
## Private Network Access
`DefaultCorsProcessor` in Spring Framework 7.0.9 also handles
`Access-Control-Request-Private-Network` / `Access-Control-Allow-Private-Network` &mdash; both
strings are in the class. If a public-origin SPA calls something on a private address, that is
the header pair to look for, and `CorsConfiguration.setAllowPrivateNetwork(true)` is the switch.
---
*Prev: [3. The three identical 403s](03-three-identical-403s.md) &middot; Next: [5. The /error dispatch](05-the-error-dispatch.md)*

View File

@@ -0,0 +1,79 @@
# 5. The `/error` dispatch, or why your 403 arrives as a 401
*Prev: [4. Preflight handlers](04-preflight-handlers.md) &middot; Next: [6. CSRF for SPAs](06-csrf-for-spas.md)*
Two of the failures in this module &mdash; the wildcard/credentials clash in chapter 4 and the
CSRF rejection in chapter 6 &mdash; produce a status code that has nothing to do with what went
wrong. Both have the same cause, and it is worth understanding once because it explains a large
fraction of confusing Spring Security bug reports.
## The mechanism
1. Something inside the chain calls `response.sendError(403, ..)` (that is what
`AccessDeniedHandlerImpl` does) or lets an exception escape `FilterChainProxy`.
2. The servlet container does not write that response. It **re-dispatches** the request
internally to `/error`, with `DispatcherType.ERROR`.
3. Spring Boot registers `springSecurityFilterChain` for **every** dispatcher type:
`SecurityFilterProperties.dispatcherTypes` defaults to `EnumSet.allOf(DispatcherType.class)`.
So the whole security chain runs again on that dispatch.
4. On the second pass, the filters that extend `OncePerRequestFilter` skip themselves &mdash;
`shouldNotFilterErrorDispatch()` defaults to `true`. `BasicAuthenticationFilter` is one of
them. The credential is never re-read.
5. The filters that extend `GenericFilterBean` do run. `AuthorizationFilter` is one of them.
6. So the second pass is **authorized but not authenticated**: `AuthorizationFilter` evaluates
`/error` against `anyRequest().authenticated()`, finds an anonymous principal, and denies it.
7. The 401 from step 6 is what reaches the client. The 403 from step 1 is gone.
The mechanism is set out in full in
[The Spring Security Filter Chain Explained](https://ankurm.com/spring-security-filter-chain-explained/);
this chapter is what it looks like when it lands on a CORS or CSRF problem.
## Proving it in one diff
The `errorpermit` profile adds one filter chain, `@Order(0)`, matching `/error` and permitting
everything. Nothing else changes.
```
./scripts/run.sh csrfnaive POST → 401, empty body, WWW-Authenticate: Basic
./scripts/run.sh csrfnaive,errorpermit POST → 403, {"status":403,"error":"Forbidden", ...}
```
[`docs/output/08-csrf-naive.txt`](output/08-csrf-naive.txt) against
[`docs/output/09-error-dispatch.txt`](output/09-error-dispatch.txt).
## What to do about it
Permit `/error`. It is not a hole: the error page is generated from an attribute the container
set, and an unauthenticated request cannot reach it except through a dispatch the container
initiated.
```java
@Bean
@Order(0)
SecurityFilterChain errorChain(HttpSecurity http) throws Exception {
return http.securityMatcher("/error")
.authorizeHttpRequests((auth) -> auth.anyRequest().permitAll())
.csrf(CsrfConfigurer::disable)
.build();
}
```
The alternative is to narrow the dispatcher types so the chain does not run on the error
dispatch at all:
```yaml
spring.security.filter.dispatcher-types: request
```
That one is broader in effect than it looks; prefer the `/error` chain unless you have a
specific reason.
## Why this matters more for a SPA than for a server-rendered app
A browser will not let a SPA read a cross-origin response unless the CORS headers are present.
When the 403 is replaced by a 401 written on a dispatch where `CorsFilter` may or may not have
re-run, what the developer sees in the console is neither "403" nor "CSRF"; it is
`TypeError: Failed to fetch`. Every layer of the stack has thrown away the actual cause by then.
---
*Prev: [4. Preflight handlers](04-preflight-handlers.md) &middot; Next: [6. CSRF for SPAs](06-csrf-for-spas.md)*

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) &middot; 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)*

View File

@@ -0,0 +1,111 @@
# 7. SameSite, `Secure`, and the cookie that is never stored
*Prev: [6. CSRF for SPAs](06-csrf-for-spas.md) &middot; Next: [8. Debugging recipes](08-debugging-recipes.md)*
CORS decides whether the browser lets your JavaScript *read* a response. SameSite decides whether
the browser *sends the cookie* in the first place. Getting CORS perfect and SameSite wrong
produces a request that arrives cleanly and is anonymous.
## What Spring emits by default
From [`docs/output/12-samesite.txt`](output/12-samesite.txt), under `csrf.spa()`:
```
Set-Cookie: XSRF-TOKEN=<token>; Path=/
Set-Cookie: JSESSIONID=<session>; Path=/; HttpOnly; SameSite=Lax
```
The session cookie gets `SameSite=Lax` from Boot's
`server.servlet.session.cookie.same-site` default. The CSRF cookie gets **no SameSite attribute
at all**: `CookieCsrfTokenRepository`'s default cookie customizer is, in bytecode, a single
`return`. Nothing is set.
An absent `SameSite` is not "no restriction". Chromium-based browsers treat it as `Lax`; Firefox has
**not** enabled Lax-by-default on its release channel (`network.cookie.sameSite.laxByDefault` is on in
Nightly only). The two disagree, which is why "it works in Firefox and not in Chrome" is so often a
missing `SameSite` attribute. `SpecCookieJar` models the Chromium behaviour, because that is the one
you have to survive.
## The two rules that matter
**Storage (RFC 6265bis &sect;5.5).** *"If the cookie's `same-site-flag` is `None` and the
cookie's `secure-only-flag` is false, then abort these steps and ignore the newly created cookie
entirely."*
`SameSite=None` without `Secure` is not a weaker cookie. It is not a cookie. No console warning
is required, no error is raised, and the server has no idea.
**Sending (RFC 6265bis &sect;5.8.3).** `Strict` and `Lax` cookies are not attached to cross-site
requests, except that `Lax` allows top-level safe-method navigations. A `fetch()` from a SPA is a
subresource request, not a top-level navigation, so `Lax` does not help it.
## Running the rules instead of quoting them
`SpecCookieJar` implements those two paragraphs in about sixty lines, and
`/diag/cookie-spec` feeds the application's own `Set-Cookie` headers through it. Over a
trustworthy origin:
| `Set-Cookie` | Stored? | Sent on a cross-site `fetch`? |
|---|---|---|
| `JSESSIONID=s1; HttpOnly; SameSite=Lax` | yes | no |
| `JSESSIONID=s2; HttpOnly; SameSite=None` | **no** | &mdash; |
| `JSESSIONID=s3; Secure; HttpOnly; SameSite=None` | yes | **yes** |
| `XSRF-TOKEN=t1` (no SameSite) | yes | no |
| `XSRF-TOKEN=t2; SameSite=None` | **no** | &mdash; |
| `XSRF-TOKEN=t3; Secure; SameSite=None` | yes | **yes** |
Two of six reach a cross-site fetch, and they are the two carrying both attributes.
## The trap that costs a day: plain `http` during development
`Secure` is only honoured from a *trustworthy* origin. Over plain `http` the attribute is
discarded, which makes `SameSite=None; Secure` collapse into `SameSite=None` with no `Secure`
&mdash; which is then rejected outright. The first block of `12-samesite.txt` is that: **nothing
survives**.
`http://localhost` is treated as trustworthy by current browsers, so it works. `http://127.0.0.1`
and `http://192.168.x.x` are not, and do not. A developer testing a cross-site SPA against a LAN
address will find that the cookie simply never appears, with no message anywhere.
## The configuration
Boot writes exactly what you tell it, and does **not** add `Secure` for you when you ask for
`none`:
```yaml
server:
servlet:
session:
cookie:
same-site: none
secure: true # required. Omit it and the cookie is discarded by the browser.
http-only: true
```
Spring Security's CSRF cookie is separate and needs its own customizer:
```java
CookieCsrfTokenRepository repository = CookieCsrfTokenRepository.withHttpOnlyFalse();
repository.setCookieCustomizer((cookie) -> cookie.sameSite("None").secure(true));
http.csrf(csrf -> csrf.spa().csrfTokenRepository(repository));
```
Order matters &mdash; chapter 6.
## Partitioned cookies (CHIPS)
`Partitioned` requires `Secure` and, in practice, `SameSite=None`. It changes the cookie's
storage key so that a cookie set in a third-party context is scoped to the top-level site that
embedded it. `SpecCookieJar` models the `Secure` requirement; it does not model partitioning,
which is noted here rather than pretended.
If your SPA and API are separate registrable domains, the honest conclusion is:
> **A same-site deployment removes this entire chapter.** Serving the SPA and the API from one
> origin, or from two subdomains of one registrable domain, means `SameSite=Lax` works, `Secure`
> is a hygiene setting rather than a prerequisite, and the preflight disappears. A reverse proxy
> in front of both is usually less work than everything above.
---
*Prev: [6. CSRF for SPAs](06-csrf-for-spas.md) &middot; Next: [8. Debugging recipes](08-debugging-recipes.md)*

View File

@@ -0,0 +1,77 @@
# 8. Debugging recipes
*Prev: [7. SameSite](07-samesite.md)*
## Turn on the two log categories first
```yaml
logging:
level:
org.springframework.web.cors: DEBUG # DefaultCorsProcessor's Skip:/Reject: lines
org.springframework.security.web.csrf: DEBUG # "Invalid CSRF token found for ..."
```
Almost every question in this subject is answered by one line from one of those two.
## Reproduce the preflight without a browser
```bash
curl -s -i -X OPTIONS http://localhost:8080/api/data \
-H 'Origin: https://spa.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type,x-xsrf-token'
```
That is the whole preflight. `scripts/preflight.sh` wraps it. Note the absence of `-u` and
`-b`: the browser sends no credentials on a preflight, and reproducing it *with* credentials
hides the bug.
## Is `CorsFilter` even in the chain?
```bash
curl -s localhost:8080/diag/chain | python3 -m json.tool
```
If `CorsFilter` is absent, no amount of MVC configuration will help &mdash; chapter 1. The
production equivalent, without a diagnostic endpoint, is the startup log:
```
Will secure any request with filters: DisableEncodeUrlFilter, ..., CorsFilter, ...
```
Grep for `with filters:`.
## Which `CorsConfigurationSource` beans exist, and what are they called?
```bash
curl -s localhost:8080/diag/cors-sources
```
`hasBeanNamedCorsConfigurationSource: false` with a `UrlBasedCorsConfigurationSource` in the list
is the chapter 2 failure exactly.
## Read the status code as a diagnosis
| Symptom | Look at |
|---|---|
| Preflight `401`/`403`, no CORS headers | Chapter 1 &mdash; no `CorsFilter` |
| Preflight `200`, no CORS headers | Chapter 2 &mdash; bean name |
| Preflight `403`, `Invalid CORS request` | Chapter 3 &mdash; read the DEBUG line |
| `401` on a request with valid credentials | Chapter 5 &mdash; the `/error` dispatch |
| `403` on a POST, `GET` is fine | Chapter 6 &mdash; CSRF |
| Cookie visible in DevTools' response, absent from the jar | Chapter 7 &mdash; `SameSite=None` with no `Secure` |
| Every request preflights, latency doubled | Chapter 1 &mdash; no `Access-Control-Max-Age` |
## Check the cookie jar, not the response
DevTools shows the `Set-Cookie` header in the Network tab whether or not the browser stored the
cookie. Application &rarr; Cookies is the jar. A header present in one and absent from the other
is chapter 7, every time.
## Delete the diagnostic endpoints
`DiagController` and `CookieSpecReport` publish your filter chain and bean names. They exist to
make this repository legible. Do not ship them.
---
*Prev: [7. SameSite](07-samesite.md)*

View File

@@ -0,0 +1,58 @@
==============================================================================
docs/output/01-mvc-only.txt
CORS configured with WebMvcConfigurer.addCorsMappings and nothing else.
Profile: mvconly
==============================================================================
# The security chain. Note what is NOT in it.
$ curl -s localhost:8080/diag/chain
{
"profiles": [
"mvconly"
],
"chains": [
{
"size": 11,
"filters": [
"DisableEncodeUrlFilter",
"WebAsyncManagerIntegrationFilter",
"SecurityContextHolderFilter",
"HeaderWriterFilter",
"LogoutFilter",
"BasicAuthenticationFilter",
"RequestCacheAwareFilter",
"SecurityContextHolderAwareRequestFilter",
"AnonymousAuthenticationFilter",
"ExceptionTranslationFilter",
"AuthorizationFilter"
]
}
]
}
# CorsConfigurationSource beans in the context.
$ curl -s localhost:8080/diag/cors-sources
{
"corsConfigurationSourceBeans": {
"mvcHandlerMappingIntrospector": "HandlerMappingIntrospector"
},
"hasBeanNamedCorsConfigurationSource": false
}
$ curl -s -i -X OPTIONS http://localhost:8080/api/data \
-H 'Origin: https://spa.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type,x-xsrf-token'
HTTP/1.1 401
Set-Cookie: JSESSIONID=<session>; Path=/; HttpOnly; SameSite=Lax
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
# The MVC CORS mapping is real - it just never runs, because the request is
# rejected at AuthorizationFilter (order 4200) and the DispatcherServlet is
# downstream of the entire filter chain.

View File

@@ -0,0 +1,56 @@
==============================================================================
docs/output/02-mvc-bridge.txt
The identical MVC CORS mapping plus one line: .cors(Customizer.withDefaults()).
Profile: mvcbridge
==============================================================================
$ curl -s localhost:8080/diag/chain
{
"profiles": [
"mvcbridge"
],
"chains": [
{
"size": 12,
"filters": [
"DisableEncodeUrlFilter",
"WebAsyncManagerIntegrationFilter",
"SecurityContextHolderFilter",
"HeaderWriterFilter",
"CorsFilter",
"LogoutFilter",
"BasicAuthenticationFilter",
"RequestCacheAwareFilter",
"SecurityContextHolderAwareRequestFilter",
"AnonymousAuthenticationFilter",
"ExceptionTranslationFilter",
"AuthorizationFilter"
]
}
]
}
$ curl -s -i -X OPTIONS http://localhost:8080/api/data \
-H 'Origin: https://spa.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type,x-xsrf-token'
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Access-Control-Allow-Origin: https://spa.example.com
Access-Control-Allow-Methods: GET,POST
Access-Control-Allow-Headers: content-type, x-xsrf-token
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 1800
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
# CorsFilter is now in the chain at order 1000, between HeaderWriterFilter (900)
# and CsrfFilter (1100), and it short-circuits the preflight before authorization
# ever sees it. Note Access-Control-Max-Age: 1800 - that default comes from MVC's
# CorsRegistration, not from CorsConfiguration.

View File

@@ -0,0 +1,36 @@
==============================================================================
docs/output/03-security-source.txt
A @Bean named corsConfigurationSource. .cors(..) is never called - it is applied for us.
Profile: securitysource
==============================================================================
$ curl -s localhost:8080/diag/cors-sources
{
"corsConfigurationSourceBeans": {
"corsConfigurationSource": "UrlBasedCorsConfigurationSource",
"mvcHandlerMappingIntrospector": "HandlerMappingIntrospector"
},
"hasBeanNamedCorsConfigurationSource": true
}
$ curl -s -i -X OPTIONS http://localhost:8080/api/data \
-H 'Origin: https://spa.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type,x-xsrf-token'
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Access-Control-Allow-Origin: https://spa.example.com
Access-Control-Allow-Methods: GET,POST
Access-Control-Allow-Headers: content-type, x-xsrf-token
Access-Control-Allow-Credentials: true
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
# Compare with 02: there is no Access-Control-Max-Age here. CorsConfiguration
# leaves maxAge null, so every single cross-origin call re-runs the preflight.

View File

@@ -0,0 +1,47 @@
==============================================================================
docs/output/04-three-identical-403s.txt
Origin not allowed, method not allowed, header not allowed. One status, one shape.
Profile: securitysource, CORS_LOG_LEVEL=DEBUG
==============================================================================
# 1. disallowed origin
HTTP/1.1 403
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
# 2. disallowed method
HTTP/1.1 403
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
# 3. disallowed request header
HTTP/1.1 403
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
# body of a rejected preflight:
Invalid CORS request
# The only thing that distinguishes them is a DEBUG line from DefaultCorsProcessor:
<timestamp> DEBUG <pid> --- [cors-csrf-samesite] [nio-8080-exec-N] o.s.web.cors.DefaultCorsProcessor : Reject: 'https://evil.example.com' origin is not allowed
<timestamp> DEBUG <pid> --- [cors-csrf-samesite] [nio-8080-exec-N] o.s.web.cors.DefaultCorsProcessor : Reject: HTTP 'DELETE' is not allowed
<timestamp> DEBUG <pid> --- [cors-csrf-samesite] [nio-8080-exec-N] o.s.web.cors.DefaultCorsProcessor : Reject: headers '[authorization]' are not allowed
<timestamp> DEBUG <pid> --- [cors-csrf-samesite] [nio-8080-exec-N] o.s.web.cors.DefaultCorsProcessor : Reject: 'https://evil.example.com' origin is not allowed

View File

@@ -0,0 +1,39 @@
==============================================================================
docs/output/05-misnamed-bean.txt
The same UrlBasedCorsConfigurationSource bean, named apiCorsSource instead of
corsConfigurationSource. It starts. The preflight returns 200. It carries no CORS headers.
Profile: misnamed
==============================================================================
$ curl -s localhost:8080/diag/cors-sources
{
"corsConfigurationSourceBeans": {
"apiCorsSource": "UrlBasedCorsConfigurationSource",
"mvcHandlerMappingIntrospector": "HandlerMappingIntrospector"
},
"hasBeanNamedCorsConfigurationSource": false
}
$ curl -s -i -X OPTIONS http://localhost:8080/api/data \
-H 'Origin: https://spa.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type,x-xsrf-token'
HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
<timestamp> DEBUG <pid> --- [cors-csrf-samesite] [nio-8080-exec-N] o.s.web.cors.DefaultCorsProcessor : Skip: no CORS configuration has been provided
# Two different lookups. HttpSecurityConfiguration.applyCorsIfAvailable asks
# getBeanNamesForType(UrlBasedCorsConfigurationSource.class) and enables CORS if the
# array is non-empty, so the bean above DID switch the configurer on.
# CorsConfigurer.getCorsConfigurationSource then asks
# containsBeanDefinition("corsConfigurationSource"), which is false, and falls back
# to Spring MVC's registrations - of which there are none.
# CorsFilter returns from every preflight whether or not it found a configuration:
# if (!isValid || CorsUtils.isPreFlightRequest(request)) { return; }
# so the OPTIONS never reaches AuthorizationFilter and the client gets a bare 200.

View File

@@ -0,0 +1,44 @@
==============================================================================
docs/output/06-two-sources.txt
Two UrlBasedCorsConfigurationSource beans. The reference documentation says Spring Security
'won't automatically configure CORS support for you, because it cannot decide which one to
use'. In 7.1.1 it configures it, and the bean NAME decides.
Profile: twosources
==============================================================================
{
"corsConfigurationSourceBeans": {
"corsConfigurationSource": "UrlBasedCorsConfigurationSource",
"adminCorsSource": "UrlBasedCorsConfigurationSource",
"mvcHandlerMappingIntrospector": "HandlerMappingIntrospector"
},
"hasBeanNamedCorsConfigurationSource": true
}
# the origin allowed by the bean named corsConfigurationSource:
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Access-Control-Allow-Origin: https://spa.example.com
Access-Control-Allow-Methods: GET,POST
Access-Control-Allow-Headers: content-type
Access-Control-Allow-Credentials: true
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
# the origin allowed by adminCorsSource, which is never consulted:
HTTP/1.1 403
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
<timestamp> DEBUG <pid> --- [cors-csrf-samesite] [nio-8080-exec-N] o.s.web.cors.DefaultCorsProcessor : Reject: 'https://admin.example.com' origin is not allowed

View File

@@ -0,0 +1,41 @@
==============================================================================
docs/output/07-wildcard-credentials.txt
allowedOrigins("*") together with allowCredentials(true). Legal to configure, illegal to
serve. The failure is thrown on the request, not at startup - and it does not surface as a 500.
Profile: wildcard
==============================================================================
HTTP/1.1 401
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
Set-Cookie: JSESSIONID=<session>; Path=/; HttpOnly; SameSite=Lax
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
# and a plain authenticated GET, with correct credentials:
HTTP/1.1 401
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
Set-Cookie: JSESSIONID=<session>; Path=/; HttpOnly; SameSite=Lax
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
java.lang.IllegalArgumentException: When allowCredentials is true, allowedOrigins cannot contain the special value "*" since that cannot be set on the "Access-Control-Allow-Origin" response header. To allow credentials to a set of origins, list them explicitly or consider using "allowedOriginPatterns" instead.
at org.springframework.web.cors.CorsConfiguration.validateAllowCredentials(CorsConfiguration.java:552) ~[spring-web-7.0.9.jar:7.0.9]
at org.springframework.web.cors.CorsConfiguration.checkOrigin(CorsConfiguration.java:678) ~[spring-web-7.0.9.jar:7.0.9]
at org.springframework.web.cors.DefaultCorsProcessor.checkOrigin(DefaultCorsProcessor.java:193) ~[spring-web-7.0.9.jar:7.0.9]
at org.springframework.web.cors.DefaultCorsProcessor.handleInternal(DefaultCorsProcessor.java:131) ~[spring-web-7.0.9.jar:7.0.9]
# 401, not 500. The exception escapes CorsFilter, Tomcat re-dispatches to /error,
# the security chain runs again on that dispatch without re-reading the credential,
# and the anonymous second pass is what answers.

View File

@@ -0,0 +1,54 @@
==============================================================================
docs/output/08-csrf-naive.txt
CookieCsrfTokenRepository.withHttpOnlyFalse() on its own - the recipe from every pre-6.0
tutorial. Three separate things go wrong.
Profile: csrfnaive
==============================================================================
# 1. The bootstrap GET. A SPA expects an XSRF-TOKEN cookie here.
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
# cookie jar after the GET:
(empty - no cookie was set)
# 2. POST with no token.
HTTP/1.1 401
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Set-Cookie: XSRF-TOKEN=<token>; Path=/
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
Set-Cookie: JSESSIONID=<session>; Path=/; HttpOnly; SameSite=Lax
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
# cookie jar now:
localhost | FALSE | / | FALSE | 0 | XSRF-TOKEN | 4888debb-2e51-4742-b0e7-262c489825b9
# 3. POST echoing the raw cookie value back in X-XSRF-TOKEN, which is what every
# SPA snippet on the internet does.
HTTP/1.1 401
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
<timestamp> DEBUG <pid> --- [cors-csrf-samesite] [nio-8080-exec-N] o.s.security.web.csrf.CsrfFilter : Invalid CSRF token found for http://localhost:8080/api/data
<timestamp> DEBUG <pid> --- [cors-csrf-samesite] [nio-8080-exec-N] o.s.security.web.csrf.CsrfFilter : Invalid CSRF token found for http://localhost:8080/api/data

View File

@@ -0,0 +1,25 @@
==============================================================================
docs/output/09-error-dispatch.txt
The identical CSRF failure, with one extra filter chain that permits /error.
Profile: csrfnaive,errorpermit
==============================================================================
HTTP/1.1 403
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Set-Cookie: XSRF-TOKEN=<token>; Path=/
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
{"timestamp":"<timestamp>","status":403,"error":"Forbidden","path":"/api/data"}
# 403, and a body. Without the /error chain the same request answers 401 with an
# empty body and a WWW-Authenticate header - see 08. AccessDeniedHandlerImpl calls
# response.sendError(403), the container re-dispatches to /error, and the security
# chain runs a second time on that dispatch. BasicAuthenticationFilter extends
# OncePerRequestFilter and skips error dispatches, so the second pass is anonymous
# and AuthorizationFilter answers 401 over the top of the 403.

View File

@@ -0,0 +1,47 @@
==============================================================================
docs/output/10-csrf-spa.txt
The same flow under csrf.spa(), added in Spring Security 7.0.
Profile: csrfspa
==============================================================================
# 1. The bootstrap GET now DOES set the cookie.
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Set-Cookie: XSRF-TOKEN=<token>; Path=/
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
# 2. POST with no token still fails, as it must.
HTTP/1.1 401
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
Set-Cookie: JSESSIONID=<session>; Path=/; HttpOnly; SameSite=Lax
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
# 3. POST echoing the raw cookie value in X-XSRF-TOKEN.
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
{"created":true,"received":"{}","cookies":"XSRF-TOKEN"}
# Note the cookie attributes: Path=/ and nothing else. No SameSite, no Secure,
# no HttpOnly. A cookie with no SameSite attribute is treated as Lax, so a
# genuinely cross-site SPA still never receives it. See 12.

View File

@@ -0,0 +1,24 @@
==============================================================================
docs/output/11-spa-ordering.txt
.csrf(c -> c.csrfTokenRepository(custom).spa()) - the custom repository asked for a cookie
named MY-CSRF and a header named X-CSRF-TOKEN. Neither reaches the running application.
Profile: spaorder
==============================================================================
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Set-Cookie: XSRF-TOKEN=<token>; Path=/
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
# cookie jar:
localhost | FALSE | / | FALSE | 0 | XSRF-TOKEN | 42573eea-76d6-4bc6-a14b-bff76640461d
# spa() assigns csrfTokenRepository and requestHandler unconditionally; it is not a
# 'defaults if unset' method. Swap the two calls and MY-CSRF appears.

View File

@@ -0,0 +1,59 @@
==============================================================================
docs/output/12-samesite.txt
The Set-Cookie headers this application emits under four configurations, and what
SpecCookieJar - a model of RFC 6265bis 5.5 and 5.8.3 - does with them.
==============================================================================
## csrf.spa() defaults, session cookie left at same-site=lax
Set-Cookie: XSRF-TOKEN=<token>; Path=/
Set-Cookie: JSESSIONID=<session>; Path=/; HttpOnly; SameSite=Lax
## session cookie set to same-site=none, secure=false
Set-Cookie: XSRF-TOKEN=<token>; Path=/
Set-Cookie: JSESSIONID=<session>; Path=/; HttpOnly; SameSite=None
## crosssite profile: SameSite=None and Secure on both cookies
Set-Cookie: XSRF-TOKEN=<token>; Path=/; Secure; SameSite=None
Set-Cookie: JSESSIONID=<session>; Path=/; Secure; HttpOnly; SameSite=None
## crosssite profile with -DOMIT_SECURE=true
Set-Cookie: XSRF-TOKEN=<token>; Path=/; SameSite=None
Set-Cookie: JSESSIONID=<session>; Path=/; HttpOnly; SameSite=None
## The same headers, run through SpecCookieJar
{
"origin": "not trustworthy (plain http)",
"setCookieOutcomes": {
"JSESSIONID=s1; Path=/; HttpOnly; SameSite=Lax": "stored",
"JSESSIONID=s2; Path=/; HttpOnly; SameSite=None": "REJECTED JSESSIONID: SameSite=None and no Secure attribute - RFC 6265bis 5.5",
"JSESSIONID=s3; Path=/; Secure; HttpOnly; SameSite=None": "REJECTED JSESSIONID: SameSite=None with Secure, but the origin is not trustworthy so Secure is not honoured - RFC 6265bis 5.5",
"XSRF-TOKEN=t1; Path=/": "stored",
"XSRF-TOKEN=t2; Path=/; SameSite=None": "REJECTED XSRF-TOKEN: SameSite=None and no Secure attribute - RFC 6265bis 5.5",
"XSRF-TOKEN=t3; Path=/; Secure; SameSite=None": "REJECTED XSRF-TOKEN: SameSite=None with Secure, but the origin is not trustworthy so Secure is not honoured - RFC 6265bis 5.5"
},
"sentOnSameSiteRequest": "JSESSIONID=s1; XSRF-TOKEN=t1",
"sentOnCrossSiteTopLevelNavigation": "JSESSIONID=s1; XSRF-TOKEN=t1",
"sentOnCrossSiteFetch": "(no cookies sent)"
}
{
"origin": "trustworthy (https, or http://localhost)",
"setCookieOutcomes": {
"JSESSIONID=s1; Path=/; HttpOnly; SameSite=Lax": "stored",
"JSESSIONID=s2; Path=/; HttpOnly; SameSite=None": "REJECTED JSESSIONID: SameSite=None and no Secure attribute - RFC 6265bis 5.5",
"JSESSIONID=s3; Path=/; Secure; HttpOnly; SameSite=None": "stored",
"XSRF-TOKEN=t1; Path=/": "stored",
"XSRF-TOKEN=t2; Path=/; SameSite=None": "REJECTED XSRF-TOKEN: SameSite=None and no Secure attribute - RFC 6265bis 5.5",
"XSRF-TOKEN=t3; Path=/; Secure; SameSite=None": "stored"
},
"sentOnSameSiteRequest": "JSESSIONID=s3; XSRF-TOKEN=t3",
"sentOnCrossSiteTopLevelNavigation": "JSESSIONID=s3; XSRF-TOKEN=t3",
"sentOnCrossSiteFetch": "JSESSIONID=s3; XSRF-TOKEN=t3"
}
# Read the second block first: over a trustworthy origin, the only two of the six
# that reach a cross-site fetch are the two carrying Secure AND SameSite=None.
# Then read the first: over plain http, none do -
# which is why a cross-site SPA cannot be developed against http://127.0.0.1.
# (http://localhost itself is treated as trustworthy by current browsers; a bare IP
# is not.)

View File

@@ -0,0 +1,34 @@
==============================================================================
docs/output/13-tests.txt
mvn -B test
==============================================================================
09:33:03.378 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper -- Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CsrfAndCookieTests$Naive
09:33:03.464 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper -- Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CsrfAndCookieTests$Naive
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.904 s -- in CookieCsrfTokenRepository.withHttpOnlyFalse() on its own
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CsrfAndCookieTests$Spa
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CsrfAndCookieTests$Spa
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.444 s -- in csrf.spa()
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.028 s -- in SpecCookieJar - the storage and sending rules a browser applies
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CsrfAndCookieTests$Ordering
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CsrfAndCookieTests$Ordering
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.362 s -- in csrfTokenRepository(..) before spa()
[INFO] Tests run: 0, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.783 s -- in com.ankurm.cors.CsrfAndCookieTests
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$MvcOnly
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$MvcOnly
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.305 s -- in CORS on the MVC layer only
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$Misnamed
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$Misnamed
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.264 s -- in the right type under the wrong bean name
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$SecuritySource
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$SecuritySource
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.225 s -- in a bean named corsConfigurationSource
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$TwoSources
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$TwoSources
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.235 s -- in two UrlBasedCorsConfigurationSource beans
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$MvcBridge
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$MvcBridge
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.217 s -- in the same MVC configuration plus .cors(withDefaults())
[INFO] Tests run: 0, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.256 s -- in com.ankurm.cors.CorsContractTests
[INFO] Tests run: 23, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS