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

@@ -10,37 +10,45 @@ by that module's `scripts/run-all.sh`, never typed by hand.
| [`context-propagation/`](context-propagation/README.md) | [Spring Security Context Propagation: The Complete Guide](https://ankurm.com/spring-security-context-propagation-complete-guide/) | Whether a `SecurityContext` survives `@Async`, executors, virtual threads, `StructuredTaskScope`, Reactor, schedulers and the servlet filter chain | | [`context-propagation/`](context-propagation/README.md) | [Spring Security Context Propagation: The Complete Guide](https://ankurm.com/spring-security-context-propagation-complete-guide/) | Whether a `SecurityContext` survives `@Async`, executors, virtual threads, `StructuredTaskScope`, Reactor, schedulers and the servlet filter chain |
| [`method-security/`](method-security/README.md) | [Method Security in Spring Security 7: `@PreAuthorize`, `@PostAuthorize` and the Proxy Traps](https://ankurm.com/spring-security-7-method-security-proxy-traps/) | What the method-security annotations do, the full SpEL surface, and the cases where the check silently does not run | | [`method-security/`](method-security/README.md) | [Method Security in Spring Security 7: `@PreAuthorize`, `@PostAuthorize` and the Proxy Traps](https://ankurm.com/spring-security-7-method-security-proxy-traps/) | What the method-security annotations do, the full SpEL surface, and the cases where the check silently does not run |
| [`filter-chain/`](filter-chain/README.md) | [The Spring Security Filter Chain Explained](https://ankurm.com/spring-security-filter-chain-explained/) | Every filter in the default chain and its order number, where a custom filter actually lands, and how to read the TRACE log | | [`filter-chain/`](filter-chain/README.md) | [The Spring Security Filter Chain Explained](https://ankurm.com/spring-security-filter-chain-explained/) | Every filter in the default chain and its order number, where a custom filter actually lands, and how to read the TRACE log |
| [`cors-csrf/`](cors-csrf/README.md) | [CORS, CSRF and SameSite in Spring Boot 4](https://ankurm.com/spring-boot-4-cors-csrf-samesite/) | Why MVC-layer CORS does not fix a security-layer preflight rejection, what `csrf.spa()` assigns, and the cookie a browser silently refuses to store |
The three are related more closely than they look. `filter-chain` is about how an They are related more closely than they look. `filter-chain` is about how an `Authentication`
`Authentication` gets into `SecurityContextHolder` in the first place and in what order; gets into `SecurityContextHolder` in the first place and in what order; `context-propagation` is
`context-propagation` is about whether it survives leaving the request thread; `method-security` about whether it survives leaving the request thread; `method-security` reads it back on whatever
reads it back on whatever thread it ends up on. An `@Async` method carrying `@PreAuthorize` fails thread it ends up on. An `@Async` method carrying `@PreAuthorize` fails with
with `AuthenticationCredentialsNotFoundException` for reasons that belong to the second module, `AuthenticationCredentialsNotFoundException` for reasons that belong to the second module, not
not the third — and a custom authentication filter that never populated the context in the first the third — and a custom authentication filter that never populated the context in the first
place fails the same way, for reasons that belong to the first. place fails the same way, for reasons that belong to the first.
`cors-csrf` is where those order numbers stop being trivia. `CorsFilter` at 1000 and `CsrfFilter`
at 1100 both sit far above `AuthorizationFilter` at 4200, and almost every confusing symptom in
that module is a consequence of one of those three positions — including a 403 that arrives as a
401 because the container re-dispatched the request to `/error` and the chain ran a second time.
## Common ground ## Common ground
All three modules target the same verified stack: **JDK 25** (Temurin 25.0.4.1+1), All modules target the same verified stack: **JDK 25** (Temurin 25.0.4.1+1),
**Spring Framework 7.0.9**, **Spring Security 7.1.1** — the versions Spring Boot **4.1.1** **Spring Framework 7.0.9**, **Spring Security 7.1.1** — the versions Spring Boot **4.1.1**
manages. Versions were taken from `maven-metadata.xml` on Maven Central rather than from manages. Versions were taken from `maven-metadata.xml` on Maven Central rather than from
release announcements. release announcements.
`context-propagation` additionally needs `--enable-preview`, because `StructuredTaskScope` is `context-propagation` additionally needs `--enable-preview`, because `StructuredTaskScope` is
still a preview API on JDK 25. `method-security` does not. `filter-chain` is the only module still a preview API on JDK 25. `method-security` does not. `filter-chain` and `cors-csrf` are
that is a real servlet application: it inherits `spring-boot-starter-parent` and runs on Tomcat, real servlet applications: they inherit `spring-boot-starter-parent` and run on Tomcat, because
because the thing it demonstrates only exists inside a servlet container. the things they demonstrate only exist inside a servlet container.
## Running a module ## Running a module
```bash ```bash
cd method-security # or context-propagation, or filter-chain cd cors-csrf # or context-propagation, method-security, filter-chain
./scripts/run-all.sh # every demo plus the test suite, regenerating docs/output/ ./scripts/run-all.sh # every demo plus the test suite, regenerating docs/output/
mvn test # just the assertions mvn test # just the assertions
``` ```
`filter-chain` also has `./scripts/run.sh <profile>` and `./scripts/stop.sh`, because its `filter-chain` and `cors-csrf` also have `./scripts/run.sh <profile>` and `./scripts/stop.sh`,
scenarios are a running web application rather than a `main()` method. because their scenarios are a running web application rather than a `main()` method.
`cors-csrf` adds `./scripts/preflight.sh`, which sends one CORS preflight and prints the headers
that decide the outcome.
## License ## License

117
cors-csrf/README.md Normal file
View File

@@ -0,0 +1,117 @@
# `cors-csrf` — CORS, CSRF and SameSite, reproduced state by state
Companion project for
[**CORS, CSRF and SameSite in Spring Boot 4: The Three Settings Everyone Gets Wrong**](https://ankurm.com/spring-boot-4-cors-csrf-samesite/)
on ankurm.com.
Every broken state the article describes is a Spring profile on this one application, and every
transcript under [`docs/output/`](docs/output/) was produced by running it. No browser is
required: a CORS preflight is an `OPTIONS` carrying two headers, and `curl` sends those.
## Versions
| | Version | Notes |
|---|---|---|
| JDK | 25 (Temurin 25.0.4.1+1) | current LTS |
| Spring Boot | 4.1.1 | inherited as parent, so everything below is Boot-managed |
| Spring Framework | 7.0.9 | `CorsFilter`, `DefaultCorsProcessor`, `CorsConfiguration` |
| Spring Security | 7.1.1 | `CorsConfigurer`, `CsrfConfigurer.spa()` |
| Tomcat | 11.0.24 | |
| JUnit Jupiter / AssertJ | Boot-managed | 23 assertions |
Versions were read from `repo1.maven.org/.../maven-metadata.xml`, not from release
announcements. Note that `maven-metadata.xml`'s own `<release>` element pointed at
`4.2.0-M1` while this was written; a milestone is not a release.
## Quickstart
```bash
./scripts/run.sh securitysource # the configuration that works
./scripts/preflight.sh # one preflight, headers printed
./scripts/run.sh mvconly # the same app with CORS on the MVC layer only
./scripts/preflight.sh # → 401
./scripts/run-all.sh # every scenario, regenerating docs/output/
mvn test # just the 23 assertions
./scripts/stop.sh
```
The user is `alice` / `password`.
## Profiles
| Profile | Configuration | Shows |
|---|---|---|
| `securitysource` *(default)* | [`SecuritySourceConfig`](src/main/java/com/ankurm/cors/config/SecuritySourceConfig.java) | A bean named `corsConfigurationSource`. This one works |
| `mvconly` | [`MvcOnlySecurityConfig`](src/main/java/com/ankurm/cors/config/MvcOnlySecurityConfig.java) | `addCorsMappings` and nothing else — preflight answered `401` |
| `mvcbridge` | [`MvcBridgeSecurityConfig`](src/main/java/com/ankurm/cors/config/MvcBridgeSecurityConfig.java) | The same MVC config plus `.cors(withDefaults())` — and MVC's `max-age` default |
| `misnamed` | [`MisnamedSourceConfig`](src/main/java/com/ankurm/cors/config/MisnamedSourceConfig.java) | Right type, wrong bean name — preflight answered `200` with no CORS headers |
| `twosources` | [`TwoSourcesConfig`](src/main/java/com/ankurm/cors/config/TwoSourcesConfig.java) | Two sources. The docs say CORS is not configured; it is, and the name decides |
| `wildcard` | [`WildcardCredentialsConfig`](src/main/java/com/ankurm/cors/config/WildcardCredentialsConfig.java) | `allowedOrigins("*")` with credentials — fails on the request, surfaces as `401` |
| `csrfnaive` | [`CsrfNaiveConfig`](src/main/java/com/ankurm/cors/config/CsrfNaiveConfig.java) | The pre-6.0 SPA recipe: no cookie on the GET, 403 on the POST |
| `csrfspa` | [`CsrfSpaConfig`](src/main/java/com/ankurm/cors/config/CsrfSpaConfig.java) | `csrf.spa()`, and why the cookie now arrives on the bootstrap GET |
| `spaorder` | [`CsrfSpaOrderConfig`](src/main/java/com/ankurm/cors/config/CsrfSpaOrderConfig.java) | `csrfTokenRepository(..)` before `spa()` — silently discarded |
| `crosssite` | [`CsrfSpaCrossSiteConfig`](src/main/java/com/ankurm/cors/config/CsrfSpaCrossSiteConfig.java) | `SameSite=None; Secure` on the CSRF cookie |
| `errorpermit` | [`ErrorDispatchConfig`](src/main/java/com/ankurm/cors/config/ErrorDispatchConfig.java) | Add-on. Combine with any other profile to see the status code the `/error` dispatch was hiding |
Add-on profiles combine: `./scripts/run.sh csrfnaive,errorpermit`.
Three environment settings change behaviour rather than configuration:
| Setting | Effect |
|---|---|
| `SESSION_SAME_SITE` / `SESSION_SECURE` | The session cookie's attributes, written straight through by Boot |
| `JVM_ARGS=-DOMIT_SECURE=true` | Under `crosssite`, emit `SameSite=None` **without** `Secure` |
| `CORS_LOG_LEVEL` / `CSRF_LOG_LEVEL` | `DEBUG` turns on the two log categories that answer almost every question here |
## Endpoints
| Endpoint | Purpose |
|---|---|
| `GET`/`POST /api/data` | The API the imaginary SPA calls |
| `GET /api/whoami` | Who the request authenticated as |
| `GET /api/boom` | Throws, so you can watch the error dispatch |
| `GET /diag/chain` | The filters `FilterChainProxy` actually holds |
| `GET /diag/cors-sources` | Every `CorsConfigurationSource` bean, by name |
| `GET /diag/cookie-spec?h=…&secure=…` | Real `Set-Cookie` headers run through the RFC 6265bis rules |
The `/diag/**` endpoints are permitted without authentication so the scripts can read them.
Delete them before shipping.
## Documentation
| Chapter | |
|---|---|
| [01](docs/01-two-layers.md) | Two layers, one word — why MVC CORS does not fix a security-layer rejection |
| [02](docs/02-who-resolves-the-source.md) | Who resolves the `CorsConfigurationSource` — by type, then by **name** |
| [03](docs/03-three-identical-403s.md) | The three identical 403s, and reading a status code as a diagnosis |
| [04](docs/04-preflight-handlers.md) | `PreFlightRequestHandler`, and the wildcard that is not allowed |
| [05](docs/05-the-error-dispatch.md) | The `/error` dispatch, or why your 403 arrives as a 401 |
| [06](docs/06-csrf-for-spas.md) | CSRF for SPAs, and what `spa()` actually assigns |
| [07](docs/07-samesite.md) | SameSite, `Secure`, and the cookie that is never stored |
| [08](docs/08-debugging-recipes.md) | Debugging recipes |
## Captured output
| File | |
|---|---|
| [01-mvc-only.txt](docs/output/01-mvc-only.txt) | An 11-filter chain with no `CorsFilter`, and a `401` preflight |
| [02-mvc-bridge.txt](docs/output/02-mvc-bridge.txt) | The same app plus one line — `200`, and `Access-Control-Max-Age: 1800` |
| [03-security-source.txt](docs/output/03-security-source.txt) | A `corsConfigurationSource` bean — `200`, and **no** max-age |
| [04-three-identical-403s.txt](docs/output/04-three-identical-403s.txt) | Three rejections, one response, three DEBUG lines |
| [05-misnamed-bean.txt](docs/output/05-misnamed-bean.txt) | `200` with no CORS headers, and `Skip: no CORS configuration has been provided` |
| [06-two-sources.txt](docs/output/06-two-sources.txt) | Two sources, CORS configured anyway, the named bean winning |
| [07-wildcard-credentials.txt](docs/output/07-wildcard-credentials.txt) | The `IllegalArgumentException`, arriving as a `401` |
| [08-csrf-naive.txt](docs/output/08-csrf-naive.txt) | No cookie on the GET, then two rejected POSTs |
| [09-error-dispatch.txt](docs/output/09-error-dispatch.txt) | The same failure with `/error` permitted — the `403` reappears |
| [10-csrf-spa.txt](docs/output/10-csrf-spa.txt) | `csrf.spa()`: cookie on the GET, raw value accepted in the header |
| [11-spa-ordering.txt](docs/output/11-spa-ordering.txt) | A custom repository silently discarded by `spa()` |
| [12-samesite.txt](docs/output/12-samesite.txt) | Four sets of real `Set-Cookie` headers, run through the RFC rules |
| [13-tests.txt](docs/output/13-tests.txt) | `mvn test` |
## Related modules
- [`filter-chain/`](../filter-chain/README.md) — the order numbers this module keeps citing, and the `/error` dispatch in full
- [`context-propagation/`](../context-propagation/README.md) — whether the `SecurityContext` survives leaving the request thread
- [`method-security/`](../method-security/README.md) — reading that context back

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** &mdash; 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** &mdash; 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` &mdash; the builder behind `addCorsMappings` &mdash; 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) &middot; 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` &mdash; "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 &mdash; 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) &middot; 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) &middot; 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 &mdash; 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 &mdash; chapter 1 |
| `200`, no `Access-Control-*` | `CorsFilter` is present and found no configuration for this path &mdash; chapter 2 |
| `403`, `Invalid CORS request` | `CorsFilter` is present and rejected origin, method or headers &mdash; 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 &mdash; **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) &middot; 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) &middot; 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

55
cors-csrf/pom.xml Normal file
View File

@@ -0,0 +1,55 @@
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<!-- Inheriting spring-boot-starter-parent (rather than importing the BOM) so this module
gets Boot's own compiler settings. Every version below is managed by the parent.
This module is a real servlet application because the thing it demonstrates - the
order in which the CORS and CSRF filters run relative to authorization - only exists
inside a servlet container. See docs/01-two-layers.md. -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>cors-csrf-samesite</artifactId>
<version>1.0</version>
<packaging>jar</packaging>
<properties>
<java.version>25</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

25
cors-csrf/scripts/preflight.sh Executable file
View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Send one CORS preflight and print the status line and the headers that decide the outcome.
#
# ./scripts/preflight.sh https://spa.example.com POST /api/data
#
# A preflight is not a special kind of request. It is an OPTIONS carrying Origin and
# Access-Control-Request-Method, and it is sent WITHOUT cookies or an Authorization header -
# which is precisely why a chain that requires authentication rejects it.
set -eu
ORIGIN="${1:-https://spa.example.com}"
METHOD="${2:-POST}"
PATH_="${3:-/api/data}"
echo "\$ curl -s -i -X OPTIONS http://localhost:8080$PATH_ \\"
echo " -H 'Origin: $ORIGIN' \\"
echo " -H 'Access-Control-Request-Method: $METHOD' \\"
echo " -H 'Access-Control-Request-Headers: content-type,x-xsrf-token'"
echo
curl -s -i -X OPTIONS "http://localhost:8080$PATH_" \
-H "Origin: $ORIGIN" \
-H "Access-Control-Request-Method: $METHOD" \
-H "Access-Control-Request-Headers: content-type,x-xsrf-token" \
| sed -n '1,/^\r$/p' \
| grep -viE '^(date|keep-alive|connection|content-length|transfer-encoding):' \
| sed 's/\r$//'

355
cors-csrf/scripts/run-all.sh Executable file
View File

@@ -0,0 +1,355 @@
#!/usr/bin/env bash
# Regenerates every file under docs/output/ from a real run. Nothing in docs/output/ is
# hand-written; if a number in the article disagrees with a file here, the file is right.
#
# ./scripts/run-all.sh
#
# Takes a few minutes: the application restarts once per scenario, because the scenarios are
# Spring profiles and profiles are fixed at context startup.
set -eu
cd "$(dirname "$0")/.."
OUT=docs/output
mkdir -p "$OUT"
hdr() { printf '%s\n%s\n%s\n\n' "$(printf '=%.0s' $(seq 1 78))" "$1" "$(printf '=%.0s' $(seq 1 78))"; }
# Strip run-to-run noise so committed files diff cleanly.
scrub() {
sed -E \
-e 's/\r$//' \
-e 's/[0-9]{4}-[0-9]{2}-[0-9]{2}[T ][0-9:.]+(Z|\+[0-9:]+)?/<timestamp>/g' \
-e 's/(JSESSIONID=)[0-9A-F]+/\1<session>/g' \
-e 's/(XSRF-TOKEN=|MY-CSRF=)[0-9a-f-]{36}/\1<token>/g' \
-e 's/(X-XSRF-TOKEN: |X-CSRF-TOKEN: )[0-9a-f-]{36}/\1<token>/g' \
-e '/^(Date|Keep-Alive|Connection|Content-Length|Transfer-Encoding|Expires):/d' \
-e 's/PID [0-9]+/PID <pid>/g' \
-e 's/in [0-9.]+ seconds \(process running for [0-9.]+\)/in <n> seconds/g' \
-e 's/ [0-9]+ --- / <pid> --- /g' \
-e 's/\[nio-8080-exec-[0-9]+\]/[nio-8080-exec-N]/g' \
-e '/Picked up JAVA_TOOL_OPTIONS/d' \
| cat -s
}
headers() { # headers <curl args...>
curl -s -i "$@" | sed -n '1,/^\r$/p' | grep -viE '^(date|keep-alive|connection|content-length|transfer-encoding|content-type|content-language):'
}
logs_since() { # logs_since <marker-line-count> <grep-pattern>
sed -n "$(( $1 + 1 )),\$p" /tmp/cors-csrf-app.log | grep -E "$2" || true
}
########################################################################################
# 1. CORS on the MVC layer only - the preflight never reaches the servlet
########################################################################################
./scripts/run.sh mvconly > /dev/null
{
hdr "docs/output/01-mvc-only.txt
CORS configured with WebMvcConfigurer.addCorsMappings and nothing else.
Profile: mvconly"
echo "# The security chain. Note what is NOT in it."
echo "\$ curl -s localhost:8080/diag/chain"
curl -s localhost:8080/diag/chain | python3 -m json.tool
echo
echo "# CorsConfigurationSource beans in the context."
echo "\$ curl -s localhost:8080/diag/cors-sources"
curl -s localhost:8080/diag/cors-sources | python3 -m json.tool
echo
./scripts/preflight.sh
echo
echo "# The MVC CORS mapping is real - it just never runs, because the request is"
echo "# rejected at AuthorizationFilter (order 4200) and the DispatcherServlet is"
echo "# downstream of the entire filter chain."
} | scrub > "$OUT/01-mvc-only.txt"
########################################################################################
# 2. The same MVC configuration, with .cors(withDefaults()) added
########################################################################################
./scripts/run.sh mvcbridge > /dev/null
{
hdr "docs/output/02-mvc-bridge.txt
The identical MVC CORS mapping plus one line: .cors(Customizer.withDefaults()).
Profile: mvcbridge"
echo "\$ curl -s localhost:8080/diag/chain"
curl -s localhost:8080/diag/chain | python3 -m json.tool
echo
./scripts/preflight.sh
echo
echo "# CorsFilter is now in the chain at order 1000, between HeaderWriterFilter (900)"
echo "# and CsrfFilter (1100), and it short-circuits the preflight before authorization"
echo "# ever sees it. Note Access-Control-Max-Age: 1800 - that default comes from MVC's"
echo "# CorsRegistration, not from CorsConfiguration."
} | scrub > "$OUT/02-mvc-bridge.txt"
########################################################################################
# 3. A CorsConfigurationSource bean, correctly named
########################################################################################
./scripts/run.sh securitysource > /dev/null
{
hdr "docs/output/03-security-source.txt
A @Bean named corsConfigurationSource. .cors(..) is never called - it is applied for us.
Profile: securitysource"
echo "\$ curl -s localhost:8080/diag/cors-sources"
curl -s localhost:8080/diag/cors-sources | python3 -m json.tool
echo
./scripts/preflight.sh
echo
echo "# Compare with 02: there is no Access-Control-Max-Age here. CorsConfiguration"
echo "# leaves maxAge null, so every single cross-origin call re-runs the preflight."
} | scrub > "$OUT/03-security-source.txt"
########################################################################################
# 4. Three rejections that look identical from the client
########################################################################################
{
hdr "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"
} > "$OUT/04-three-identical-403s.txt"
CORS_LOG_LEVEL=DEBUG ./scripts/run.sh securitysource > /dev/null
MARK=$(wc -l < /tmp/cors-csrf-app.log)
{
echo "# 1. disallowed origin"
headers -X OPTIONS localhost:8080/api/data -H 'Origin: https://evil.example.com' -H 'Access-Control-Request-Method: POST'
echo "# 2. disallowed method"
headers -X OPTIONS localhost:8080/api/data -H 'Origin: https://spa.example.com' -H 'Access-Control-Request-Method: DELETE'
echo "# 3. disallowed request header"
headers -X OPTIONS localhost:8080/api/data -H 'Origin: https://spa.example.com' -H 'Access-Control-Request-Method: POST' -H 'Access-Control-Request-Headers: authorization'
echo "# body of a rejected preflight:"
curl -s -X OPTIONS localhost:8080/api/data -H 'Origin: https://evil.example.com' -H 'Access-Control-Request-Method: POST'
echo
echo
echo "# The only thing that distinguishes them is a DEBUG line from DefaultCorsProcessor:"
sleep 1
logs_since "$MARK" 'DefaultCorsProcessor'
} | scrub >> "$OUT/04-three-identical-403s.txt"
########################################################################################
# 5. The bean-name trap: right type, wrong name
########################################################################################
CORS_LOG_LEVEL=DEBUG ./scripts/run.sh misnamed > /dev/null
MARK=$(wc -l < /tmp/cors-csrf-app.log)
{
hdr "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"
echo "\$ curl -s localhost:8080/diag/cors-sources"
curl -s localhost:8080/diag/cors-sources | python3 -m json.tool
echo
./scripts/preflight.sh
echo
sleep 1
logs_since "$MARK" 'DefaultCorsProcessor'
echo
echo "# Two different lookups. HttpSecurityConfiguration.applyCorsIfAvailable asks"
echo "# getBeanNamesForType(UrlBasedCorsConfigurationSource.class) and enables CORS if the"
echo "# array is non-empty, so the bean above DID switch the configurer on."
echo "# CorsConfigurer.getCorsConfigurationSource then asks"
echo "# containsBeanDefinition(\"corsConfigurationSource\"), which is false, and falls back"
echo "# to Spring MVC's registrations - of which there are none."
echo "# CorsFilter returns from every preflight whether or not it found a configuration:"
echo "# if (!isValid || CorsUtils.isPreFlightRequest(request)) { return; }"
echo "# so the OPTIONS never reaches AuthorizationFilter and the client gets a bare 200."
} | scrub > "$OUT/05-misnamed-bean.txt"
########################################################################################
# 6. Two sources - the documentation says CORS is not configured. It is.
########################################################################################
CORS_LOG_LEVEL=DEBUG ./scripts/run.sh twosources > /dev/null
MARK=$(wc -l < /tmp/cors-csrf-app.log)
{
hdr "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"
curl -s localhost:8080/diag/cors-sources | python3 -m json.tool
echo
echo "# the origin allowed by the bean named corsConfigurationSource:"
headers -X OPTIONS localhost:8080/api/data -H 'Origin: https://spa.example.com' -H 'Access-Control-Request-Method: POST' -H 'Access-Control-Request-Headers: content-type'
echo "# the origin allowed by adminCorsSource, which is never consulted:"
headers -X OPTIONS localhost:8080/api/data -H 'Origin: https://admin.example.com' -H 'Access-Control-Request-Method: POST' -H 'Access-Control-Request-Headers: content-type'
sleep 1
logs_since "$MARK" 'DefaultCorsProcessor'
} | scrub > "$OUT/06-two-sources.txt"
########################################################################################
# 7. allowedOrigins("*") with allowCredentials(true)
########################################################################################
./scripts/run.sh wildcard > /dev/null
MARK=$(wc -l < /tmp/cors-csrf-app.log)
{
hdr "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"
headers -X OPTIONS localhost:8080/api/data -H 'Origin: https://spa.example.com' -H 'Access-Control-Request-Method: POST'
echo "# and a plain authenticated GET, with correct credentials:"
headers -u alice:password localhost:8080/api/data -H 'Origin: https://spa.example.com'
echo
sleep 1
logs_since "$MARK" 'IllegalArgumentException: When allowCredentials|at org.springframework.web.cors' | head -5
echo
echo "# 401, not 500. The exception escapes CorsFilter, Tomcat re-dispatches to /error,"
echo "# the security chain runs again on that dispatch without re-reading the credential,"
echo "# and the anonymous second pass is what answers."
} | scrub > "$OUT/07-wildcard-credentials.txt"
########################################################################################
# 8. CSRF for a SPA: the pre-6.0 recipe
########################################################################################
CSRF_LOG_LEVEL=DEBUG ./scripts/run.sh csrfnaive > /dev/null
MARK=$(wc -l < /tmp/cors-csrf-app.log)
J=$(mktemp); rm -f "$J"
{
hdr "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"
echo "# 1. The bootstrap GET. A SPA expects an XSRF-TOKEN cookie here."
headers -c "$J" -u alice:password localhost:8080/api/data
echo "# cookie jar after the GET:"
{ grep -v '^#' "$J" | sed 's/\t/ | /g' | grep . || echo "(empty - no cookie was set)"; }
echo
echo "# 2. POST with no token."
headers -b "$J" -c "$J" -u alice:password -X POST localhost:8080/api/data -H 'Content-Type: application/json' -d '{}'
echo "# cookie jar now:"
grep -v '^#' "$J" | sed 's/\t/ | /g'
echo
echo "# 3. POST echoing the raw cookie value back in X-XSRF-TOKEN, which is what every"
echo "# SPA snippet on the internet does."
TOK=$(grep XSRF-TOKEN "$J" | awk '{print $NF}')
headers -b "$J" -u alice:password -X POST localhost:8080/api/data -H "X-XSRF-TOKEN: $TOK" -H 'Content-Type: application/json' -d '{}'
echo
sleep 1
logs_since "$MARK" 'CsrfFilter'
} | scrub > "$OUT/08-csrf-naive.txt"
########################################################################################
# 9. The same failure with /error permitted - the status the SPA never sees
########################################################################################
CSRF_LOG_LEVEL=DEBUG ./scripts/run.sh csrfnaive,errorpermit > /dev/null
{
hdr "docs/output/09-error-dispatch.txt
The identical CSRF failure, with one extra filter chain that permits /error.
Profile: csrfnaive,errorpermit"
headers -u alice:password -X POST localhost:8080/api/data -H 'Content-Type: application/json' -d '{}'
curl -s -u alice:password -X POST localhost:8080/api/data -H 'Content-Type: application/json' -d '{}'
echo
echo
echo "# 403, and a body. Without the /error chain the same request answers 401 with an"
echo "# empty body and a WWW-Authenticate header - see 08. AccessDeniedHandlerImpl calls"
echo "# response.sendError(403), the container re-dispatches to /error, and the security"
echo "# chain runs a second time on that dispatch. BasicAuthenticationFilter extends"
echo "# OncePerRequestFilter and skips error dispatches, so the second pass is anonymous"
echo "# and AuthorizationFilter answers 401 over the top of the 403."
} | scrub > "$OUT/09-error-dispatch.txt"
########################################################################################
# 10. csrf.spa()
########################################################################################
./scripts/run.sh csrfspa > /dev/null
J=$(mktemp); rm -f "$J"
{
hdr "docs/output/10-csrf-spa.txt
The same flow under csrf.spa(), added in Spring Security 7.0.
Profile: csrfspa"
echo "# 1. The bootstrap GET now DOES set the cookie."
headers -c "$J" -u alice:password localhost:8080/api/data
echo
echo "# 2. POST with no token still fails, as it must."
headers -b "$J" -u alice:password -X POST localhost:8080/api/data -H 'Content-Type: application/json' -d '{}'
echo
echo "# 3. POST echoing the raw cookie value in X-XSRF-TOKEN."
TOK=$(grep XSRF-TOKEN "$J" | awk '{print $NF}')
headers -b "$J" -u alice:password -X POST localhost:8080/api/data -H "X-XSRF-TOKEN: $TOK" -H 'Content-Type: application/json' -d '{}'
curl -s -b "$J" -u alice:password -X POST localhost:8080/api/data -H "X-XSRF-TOKEN: $TOK" -H 'Content-Type: application/json' -d '{}'
echo
echo
echo "# Note the cookie attributes: Path=/ and nothing else. No SameSite, no Secure,"
echo "# no HttpOnly. A cookie with no SameSite attribute is treated as Lax, so a"
echo "# genuinely cross-site SPA still never receives it. See 12."
} | scrub > "$OUT/10-csrf-spa.txt"
########################################################################################
# 11. spa() discards a repository configured before it
########################################################################################
./scripts/run.sh spaorder > /dev/null
J=$(mktemp); rm -f "$J"
{
hdr "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"
headers -c "$J" -u alice:password localhost:8080/api/data
echo "# cookie jar:"
grep -v '^#' "$J" | sed 's/\t/ | /g'
echo
echo "# spa() assigns csrfTokenRepository and requestHandler unconditionally; it is not a"
echo "# 'defaults if unset' method. Swap the two calls and MY-CSRF appears."
} | scrub > "$OUT/11-spa-ordering.txt"
########################################################################################
# 12. SameSite - what is actually written, and what a browser does with it
########################################################################################
{
hdr "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."
} > "$OUT/12-samesite.txt"
emit() { # emit <label> <env...>
local label="$1"; shift
env "$@" ./scripts/run.sh "$PROFILE" > /dev/null
echo "## $label"
curl -s -D- -o /dev/null -u alice:password localhost:8080/api/data | grep -i '^set-cookie' | sed 's/\r$//'
curl -s -D- -o /dev/null localhost:8080/api/data | grep -i '^set-cookie' | grep -i jsessionid | sed 's/\r$//' || true
echo
}
{
PROFILE=csrfspa
emit "csrf.spa() defaults, session cookie left at same-site=lax" SESSION_SAME_SITE=lax SESSION_SECURE=false
emit "session cookie set to same-site=none, secure=false" SESSION_SAME_SITE=none SESSION_SECURE=false
PROFILE=crosssite
emit "crosssite profile: SameSite=None and Secure on both cookies" SESSION_SAME_SITE=none SESSION_SECURE=true
emit "crosssite profile with -DOMIT_SECURE=true" JVM_ARGS=-DOMIT_SECURE=true SESSION_SAME_SITE=none SESSION_SECURE=false
} | scrub >> "$OUT/12-samesite.txt"
./scripts/run.sh csrfspa > /dev/null
{
echo "## The same headers, run through SpecCookieJar"
python3 - <<'PY'
import urllib.parse, urllib.request, json
headers = [
"JSESSIONID=s1; Path=/; HttpOnly; SameSite=Lax",
"JSESSIONID=s2; Path=/; HttpOnly; SameSite=None",
"JSESSIONID=s3; Path=/; Secure; HttpOnly; SameSite=None",
"XSRF-TOKEN=t1; Path=/",
"XSRF-TOKEN=t2; Path=/; SameSite=None",
"XSRF-TOKEN=t3; Path=/; Secure; SameSite=None",
]
query = "&".join("h=" + urllib.parse.quote(h) for h in headers)
for secure in ("false", "true"):
url = f"http://localhost:8080/diag/cookie-spec?{query}&secure={secure}"
print(json.dumps(json.load(urllib.request.urlopen(url)), indent=2))
print()
PY
echo "# Read the second block first: over a trustworthy origin, the only two of the six"
echo "# that reach a cross-site fetch are the two carrying Secure AND SameSite=None."
echo "# Then read the first: over plain http, none do -"
echo "# which is why a cross-site SPA cannot be developed against http://127.0.0.1."
echo "# (http://localhost itself is treated as trustworthy by current browsers; a bare IP"
echo "# is not.)"
} | scrub >> "$OUT/12-samesite.txt"
########################################################################################
# 13. The assertions
########################################################################################
{
hdr "docs/output/13-tests.txt
mvn -B test"
(cd . && mvn -B test 2>&1) | grep -E 'Tests run|ERROR|BUILD|CorsCsrf' | head -30
} | scrub > "$OUT/13-tests.txt"
./scripts/stop.sh
echo "regenerated $(ls "$OUT" | wc -l) files under $OUT"

37
cors-csrf/scripts/run.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Start the demo application under a given profile and wait until it answers.
#
# ./scripts/run.sh securitysource
# ./scripts/run.sh mvconly
# SESSION_SAME_SITE=none SESSION_SECURE=false ./scripts/run.sh crosssite
# JVM_ARGS=-DOMIT_SECURE=true ./scripts/run.sh crosssite
# CSRF_LOG_LEVEL=DEBUG ./scripts/run.sh csrfnaive
#
# Two profiles are expected to FAIL to start - `misnamed` and `preflightclash`. That is what
# they demonstrate, so this script returns 1 for them and the transcript keeps the exception.
set -eu
cd "$(dirname "$0")/.."
PROFILE="${1:-securitysource}"
LOG="${LOG:-/tmp/cors-csrf-app.log}"
./scripts/stop.sh
setsid nohup mvn -B org.springframework.boot:spring-boot-maven-plugin:run \
-Dspring-boot.run.profiles="$PROFILE" \
-Dspring-boot.run.jvmArguments="${JVM_ARGS:-}" \
> "$LOG" 2>&1 < /dev/null &
for _ in $(seq 1 90); do
if curl -sf -o /dev/null http://localhost:8080/diag/chain 2>/dev/null; then
echo "started with profile: $PROFILE (log: $LOG)"
exit 0
fi
if grep -q 'APPLICATION FAILED TO START' "$LOG" 2>/dev/null; then
echo "application failed to start under profile: $PROFILE (log: $LOG)" >&2
exit 1
fi
sleep 2
done
echo "application did not become ready; see $LOG" >&2
exit 1

11
cors-csrf/scripts/stop.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Stop the demo application.
#
# Note the bracket in the grep pattern: it stops the pattern matching this script's own
# process. Match the MAIN CLASS, never 'spring-boot' - that pattern also matches the shell
# command line that started the application, so pkill -f 'spring-boot' kills your own shell.
set -eu
for pid in $(ps -eo pid,cmd | grep '[C]orsCsrfApplication' | awk '{print $1}'); do
kill -9 "$pid" 2>/dev/null || true
done
sleep 1

View File

@@ -0,0 +1,24 @@
package com.ankurm.cors;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Companion application for
* <a href="https://ankurm.com/spring-boot-4-cors-csrf-samesite/">CORS, CSRF and SameSite in
* Spring Boot 4</a>.
*
* <p>Every scenario in the article is a Spring profile on this one application. Start it with
* {@code ./scripts/run.sh <profile>} and drive it with {@code curl}; nothing here needs a
* browser, because a preflight request is just an {@code OPTIONS} with two headers.
*
* <p>See <a href="../../../../docs/01-two-layers.md">docs/01-two-layers.md</a> for why the same
* CORS configuration behaves differently depending on which layer you put it on.
*/
@SpringBootApplication
public class CorsCsrfApplication {
public static void main(String[] args) {
SpringApplication.run(CorsCsrfApplication.class, args);
}
}

View File

@@ -0,0 +1,58 @@
package com.ankurm.cors.config;
import java.util.List;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
/**
* Profile {@code csrfnaive}: {@code CookieCsrfTokenRepository.withHttpOnlyFalse()} on its own.
*
* <p>This is the recipe in every SPA tutorial written before Spring Security 6, and since 6.0
* it produces a 403 on the first POST. The default {@code CsrfTokenRequestHandler} is
* {@code XorCsrfTokenRequestAttributeHandler}: the value written into the {@code XSRF-TOKEN}
* cookie is XOR-masked against a per-response random, so the raw cookie value the SPA reads and
* echoes back in {@code X-XSRF-TOKEN} is not the value the server compares against.
*
* <p>Two more things go wrong here and both are visible in the transcripts:
* the token is <em>deferred</em>, so a plain {@code GET} does not set the cookie at all unless
* something dereferences the token; and the cookie carries no {@code SameSite} attribute, which
* browsers treat as {@code Lax}, so a cross-site SPA never receives it. See
* <a href="../../../../docs/06-csrf-for-spas.md">docs/06-csrf-for-spas.md</a>.
*/
@Configuration
@Profile("csrfnaive")
public class CsrfNaiveConfig {
@Bean
UrlBasedCorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("https://spa.example.com"));
configuration.setAllowedMethods(List.of("GET", "POST"));
configuration.setAllowedHeaders(List.of("Content-Type", "X-XSRF-TOKEN"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
@Bean
@Order(1)
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/diag/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf((csrf) -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
.build();
}
}

View File

@@ -0,0 +1,59 @@
package com.ankurm.cors.config;
import java.util.List;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
/**
* Profile {@code csrfspa}: {@code csrf.spa()}, added in Spring Security 7.0.
*
* <p>Disassembling {@code CsrfConfigurer.spa()} shows exactly two assignments: the repository
* becomes {@code CookieCsrfTokenRepository.withHttpOnlyFalse()} and the request handler becomes
* the package-private {@code SpaCsrfTokenRequestHandler}. That handler holds two delegates
* &mdash; a plain {@code CsrfTokenRequestAttributeHandler} with
* {@code setCsrfRequestAttributeName(null)}, and an {@code XorCsrfTokenRequestAttributeHandler}
* &mdash; writes with the XOR one and, on resolve, uses the plain one whenever the request
* carries the header. Header-carrying SPA requests compare raw values; form posts keep the
* BREACH masking.
*
* <p>Because {@code spa()} assigns both fields unconditionally, calling
* {@code csrfTokenRepository(..)} before it is silently discarded. See
* <a href="../../../../docs/06-csrf-for-spas.md">docs/06-csrf-for-spas.md</a> and
* {@link CsrfSpaOrderConfig}.
*/
@Configuration
@Profile("csrfspa")
public class CsrfSpaConfig {
@Bean
UrlBasedCorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("https://spa.example.com"));
configuration.setAllowedMethods(List.of("GET", "POST"));
configuration.setAllowedHeaders(List.of("Content-Type", "X-XSRF-TOKEN"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
@Bean
@Order(1)
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/diag/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf((csrf) -> csrf.spa())
.build();
}
}

View File

@@ -0,0 +1,69 @@
package com.ankurm.cors.config;
import java.util.List;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
/**
* Profile {@code crosssite}: what {@code spa()} still does not do for a genuinely cross-site
* SPA, and the two cookie attributes you have to add yourself.
*
* <p>{@code spa()} leaves the {@code XSRF-TOKEN} cookie with no {@code SameSite} attribute
* &mdash; {@code CookieCsrfTokenRepository}'s default cookie customizer is an empty lambda,
* confirmed in the bytecode. A cookie with no {@code SameSite} is treated as {@code Lax}, so
* it is not sent on a cross-site {@code fetch}. Setting {@code SameSite=None} without
* {@code Secure} does not help either: the browser rejects the whole {@code Set-Cookie}
* (RFC 6265bis &sect;5.5). Both attributes are required, together.
*
* <p>The same applies to the session cookie, which is Boot's concern rather than Spring
* Security's &mdash; see {@code application.yml} and
* <a href="../../../../docs/07-samesite.md">docs/07-samesite.md</a>.
*
* <p>Run with {@code -DOMIT_SECURE=true} to emit {@code SameSite=None} <em>without</em>
* {@code Secure} and watch {@code SpecCookieJar} reject it.
*/
@Configuration
@Profile("crosssite")
public class CsrfSpaCrossSiteConfig {
@Bean
UrlBasedCorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("https://spa.example.com"));
configuration.setAllowedMethods(List.of("GET", "POST"));
configuration.setAllowedHeaders(List.of("Content-Type", "X-XSRF-TOKEN"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
@Bean
@Order(1)
SecurityFilterChain chain(HttpSecurity http) throws Exception {
boolean omitSecure = Boolean.getBoolean("OMIT_SECURE");
CookieCsrfTokenRepository repository = CookieCsrfTokenRepository.withHttpOnlyFalse();
repository.setCookieCustomizer((cookie) -> {
cookie.sameSite("None");
// The point of the flag: SameSite=None and Secure are a pair. Emitting one
// without the other produces a Set-Cookie that every browser discards.
cookie.secure(!omitSecure);
});
return http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/diag/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf((csrf) -> csrf.spa().csrfTokenRepository(repository))
.build();
}
}

View File

@@ -0,0 +1,60 @@
package com.ankurm.cors.config;
import java.util.List;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
/**
* Profile {@code spaorder}: {@code csrfTokenRepository(..)} placed <em>before</em>
* {@code spa()}, which throws it away.
*
* <p>{@code spa()} is not a "defaults if unset" method. Its two statements are unconditional
* field assignments, so the custom cookie name below never reaches the running application and
* the SPA gets a 403 while looking at a configuration that appears to say otherwise. Swap the
* two calls and it works. {@link CsrfSpaCrossSiteConfig} relies on that ordering.
*
* <p>This is <a href="https://github.com/spring-projects/spring-security/issues/18718">
* spring-security#18718</a>, and the surprising part is that the fix is a reordering rather
* than a different API.
*/
@Configuration
@Profile("spaorder")
public class CsrfSpaOrderConfig {
@Bean
UrlBasedCorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("https://spa.example.com"));
configuration.setAllowedMethods(List.of("GET", "POST"));
configuration.setAllowedHeaders(List.of("Content-Type", "X-XSRF-TOKEN", "X-CSRF-TOKEN"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
@Bean
@Order(1)
SecurityFilterChain chain(HttpSecurity http) throws Exception {
CookieCsrfTokenRepository repository = CookieCsrfTokenRepository.withHttpOnlyFalse();
repository.setCookieName("MY-CSRF");
repository.setHeaderName("X-CSRF-TOKEN");
return http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/diag/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
// Deliberately the wrong way round.
.csrf((csrf) -> csrf.csrfTokenRepository(repository).spa())
.build();
}
}

View File

@@ -0,0 +1,39 @@
package com.ankurm.cors.config;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
/**
* Add-on profile {@code errorpermit}: a first filter chain that matches {@code /error} and
* permits everything, so the container's error dispatch stops rewriting the status code.
*
* <p>Without it, a rejection raised inside the chain calls {@code response.sendError(403, ..)},
* Tomcat re-dispatches the request to {@code /error}, and the security chain runs a second time
* on that dispatch. {@code BasicAuthenticationFilter} extends {@code OncePerRequestFilter} and
* skips error dispatches, so the credential is never re-read and the second pass is anonymous.
* {@code AuthorizationFilter} then denies it and the client receives <b>401</b> &mdash; the
* original 403 is gone.
*
* <p>Combine it with any other profile: {@code ./scripts/run.sh csrfnaive,errorpermit}.
* See <a href="../../../../docs/05-the-error-dispatch.md">docs/05-the-error-dispatch.md</a>, and
* <a href="https://ankurm.com/spring-security-filter-chain-explained/">The Spring Security
* Filter Chain Explained</a> for the mechanism in full.
*/
@Configuration
@Profile("errorpermit")
public class ErrorDispatchConfig {
@Bean
@Order(0)
SecurityFilterChain errorChain(HttpSecurity http) throws Exception {
return http
.securityMatcher("/error")
.authorizeHttpRequests((auth) -> auth.anyRequest().permitAll())
.csrf((csrf) -> csrf.disable())
.build();
}
}

View File

@@ -0,0 +1,56 @@
package com.ankurm.cors.config;
import java.util.List;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
/**
* Profile {@code misnamed}: the same bean as {@link SecuritySourceConfig}, under a different
* name. This is the gap between the two lookups.
*
* <p>{@code HttpSecurityConfiguration.applyCorsIfAvailable} asks
* {@code getBeanNamesForType(UrlBasedCorsConfigurationSource.class)} and enables the CORS
* configurer if the array is non-empty &mdash; so the bean below <em>does</em> switch CORS on.
* {@code CorsConfigurer.getCorsConfigurationSource} then asks
* {@code containsBeanDefinition("corsConfigurationSource")}, which is false, and falls through
* to Spring MVC's registrations. There are none, so startup fails with
* {@code NoSuchBeanDefinitionException}.
*
* <p>The message it prints names three fixes and does not mention the one that applies:
* rename your bean.
*/
@Configuration
@Profile("misnamed")
public class MisnamedSourceConfig {
@Bean
UrlBasedCorsConfigurationSource apiCorsSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("https://spa.example.com"));
configuration.setAllowedMethods(List.of("GET", "POST"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
@Bean
@Order(1)
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/diag/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf((csrf) -> csrf.disable())
.build();
}
}

View File

@@ -0,0 +1,40 @@
package com.ankurm.cors.config;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
/**
* Profile {@code mvcbridge}: the same MVC CORS mapping as {@code mvconly}, plus one line.
*
* <p>{@code .cors(Customizer.withDefaults())} makes {@code CorsConfigurer} run. With no bean
* named {@code corsConfigurationSource} in the context it falls back to
* {@code CorsConfigurer.MvcCorsFilter.getMvcCorsConfigurationSource(..)}, which reads the
* registrations made by {@link MvcCorsConfig}. The resulting {@code CorsFilter} goes into the
* chain at order 1000 &mdash; before {@code CsrfFilter} (1100) and a long way before
* {@code AuthorizationFilter} (4200) &mdash; and short-circuits the preflight.
*
* <p>So MVC CORS configuration <em>can</em> drive the security layer. It just does not do so by
* itself.
*/
@Configuration
@Profile("mvcbridge")
public class MvcBridgeSecurityConfig {
@Bean
@Order(1)
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.cors(Customizer.withDefaults())
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/diag/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf((csrf) -> csrf.disable())
.build();
}
}

View File

@@ -0,0 +1,34 @@
package com.ankurm.cors.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* CORS configured on the MVC layer &mdash; the first thing everybody tries, and the thing that
* does not fix a preflight rejection on its own.
*
* <p>This registers a {@code CorsConfiguration} with Spring MVC's
* {@code AbstractHandlerMapping}. It is consulted inside {@code DispatcherServlet}, which is
* downstream of the entire security filter chain. If the preflight never reaches the servlet,
* this configuration never runs. See
* <a href="../../../../docs/01-two-layers.md">docs/01-two-layers.md</a>.
*
* <p>Active under the {@code mvconly} and {@code mvcbridge} profiles. The two profiles share
* this file and differ only in whether the security chain enables CORS &mdash; which is the
* whole point.
*/
@Configuration
@Profile({ "mvconly", "mvcbridge" })
public class MvcCorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://spa.example.com")
.allowedMethods("GET", "POST")
.allowedHeaders("Content-Type", "X-XSRF-TOKEN")
.allowCredentials(true);
}
}

View File

@@ -0,0 +1,38 @@
package com.ankurm.cors.config;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
/**
* Profile {@code mvconly}: MVC has a CORS mapping, the security chain does not.
*
* <p>There is no {@code UrlBasedCorsConfigurationSource} bean here, so Spring Security's
* {@code HttpSecurityConfiguration.applyCorsIfAvailable} does not switch CORS on, so no
* {@code CorsFilter} enters the chain. The preflight {@code OPTIONS} therefore travels the
* whole chain and is judged by {@code AuthorizationFilter} at order 4200, which sees an
* anonymous request and rejects it. The browser reports a CORS error; the server log shows an
* authentication failure. Those are the same event.
*
* <p>Reproduce: {@code ./scripts/scenario-cors.sh mvconly}.
*/
@Configuration
@Profile("mvconly")
public class MvcOnlySecurityConfig {
@Bean
@Order(1)
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/diag/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf((csrf) -> csrf.disable())
.build();
}
}

View File

@@ -0,0 +1,55 @@
package com.ankurm.cors.config;
import java.util.List;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
/**
* Profile {@code securitysource}: the configuration that actually works, and the name that
* makes it work.
*
* <p>The bean method is called {@code corsConfigurationSource} on purpose. That literal string
* appears in {@code CorsConfigurer.getCorsConfigurationSource(..)} as a
* {@code containsBeanDefinition} check. Rename this method and the behaviour changes &mdash;
* see {@link MisnamedSourceConfig}.
*
* <p>Note also that {@code .cors(..)} is never called below. It does not need to be: with a
* {@code UrlBasedCorsConfigurationSource} bean present, {@code HttpSecurityConfiguration}
* applies the CORS configurer for you.
*/
@Configuration
@Profile("securitysource")
public class SecuritySourceConfig {
@Bean
UrlBasedCorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("https://spa.example.com"));
configuration.setAllowedMethods(List.of("GET", "POST"));
configuration.setAllowedHeaders(List.of("Content-Type", "X-XSRF-TOKEN"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
@Bean
@Order(1)
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/diag/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf((csrf) -> csrf.disable())
.build();
}
}

View File

@@ -0,0 +1,65 @@
package com.ankurm.cors.config;
import java.util.List;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
/**
* Profile {@code twosources}: two {@code UrlBasedCorsConfigurationSource} beans, one of which
* carries the magic name.
*
* <p>The reference documentation says that with more than one such bean "Spring Security won't
* automatically configure CORS support for you, because it cannot decide which one to use".
* In 7.1.1 that is not what the bytecode does: {@code applyCorsIfAvailable} tests
* {@code getBeanNamesForType(..).length} with {@code ifle}, i.e. "greater than zero", not
* "exactly one". CORS is applied, and the bean named {@code corsConfigurationSource} wins.
* {@code adminCorsSource} is never consulted on this chain.
*
* <p>Verified by {@code /diag/cors-sources} plus the preflight transcripts in
* <a href="../../../../docs/output/">docs/output/</a>.
*/
@Configuration
@Profile("twosources")
public class TwoSourcesConfig {
@Bean
UrlBasedCorsConfigurationSource corsConfigurationSource() {
return sourceFor("https://spa.example.com");
}
@Bean
UrlBasedCorsConfigurationSource adminCorsSource() {
return sourceFor("https://admin.example.com");
}
private static UrlBasedCorsConfigurationSource sourceFor(String origin) {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of(origin));
configuration.setAllowedMethods(List.of("GET", "POST"));
configuration.setAllowedHeaders(List.of("Content-Type", "X-XSRF-TOKEN"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
@Bean
@Order(1)
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/diag/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf((csrf) -> csrf.disable())
.build();
}
}

View File

@@ -0,0 +1,18 @@
package com.ankurm.cors.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
/** One user, {@code alice}/{@code password}, shared by every profile. */
@Configuration
public class Users {
@Bean
UserDetailsService userDetailsService() {
return new InMemoryUserDetailsManager(
User.withUsername("alice").password("{noop}password").roles("USER").build());
}
}

View File

@@ -0,0 +1,55 @@
package com.ankurm.cors.config;
import java.util.List;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
/**
* Profile {@code wildcard}: {@code allowedOrigins("*")} together with
* {@code allowCredentials(true)}.
*
* <p>This is the combination every "just make CORS work" answer suggests, and it is illegal
* under the Fetch standard: a response may not carry both
* {@code Access-Control-Allow-Origin: *} and {@code Access-Control-Allow-Credentials: true}.
* Spring does not reject it at startup. It rejects it on the first preflight, from inside
* {@code CorsConfiguration.checkOrigin}, which means the failure surfaces as a 500 on an
* {@code OPTIONS} request rather than as a configuration error.
*
* <p>The fix is {@code setAllowedOriginPatterns(..)}, which echoes the request origin back
* instead of a literal asterisk.
*/
@Configuration
@Profile("wildcard")
public class WildcardCredentialsConfig {
@Bean
UrlBasedCorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("*"));
configuration.setAllowedMethods(List.of("GET", "POST"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
@Bean
@Order(1)
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/diag/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf((csrf) -> csrf.disable())
.build();
}
}

View File

@@ -0,0 +1,46 @@
package com.ankurm.cors.spec;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* Runs the {@code Set-Cookie} headers this application actually emits through
* {@link SpecCookieJar} and reports what a browser would do with them.
*
* <p>Pass real headers with repeated {@code ?h=} parameters &mdash;
* {@code scripts/scenario-samesite.sh} collects them from a live response and feeds them back
* in, so the input is never typed by hand.
*/
@RestController
public class CookieSpecReport {
@GetMapping("/diag/cookie-spec")
public Map<String, Object> report(@RequestParam("h") List<String> headers,
@RequestParam(name = "secure", defaultValue = "false") boolean secureContext) {
SpecCookieJar jar = new SpecCookieJar();
Map<String, String> outcomes = new LinkedHashMap<>();
for (String header : headers) {
String rejection = jar.setCookie(header, secureContext);
outcomes.put(header, (rejection == null) ? "stored" : rejection);
}
Map<String, Object> out = new LinkedHashMap<>();
out.put("origin", secureContext ? "trustworthy (https, or http://localhost)" : "not trustworthy (plain http)");
out.put("setCookieOutcomes", outcomes);
out.put("sentOnSameSiteRequest",
orNone(jar.cookieHeaderFor(SpecCookieJar.Context.SAME_SITE, true)));
out.put("sentOnCrossSiteTopLevelNavigation",
orNone(jar.cookieHeaderFor(SpecCookieJar.Context.CROSS_SITE_TOP_LEVEL_NAVIGATION, true)));
out.put("sentOnCrossSiteFetch",
orNone(jar.cookieHeaderFor(SpecCookieJar.Context.CROSS_SITE_SUBRESOURCE, false)));
return out;
}
private static String orNone(String header) {
return header.isEmpty() ? "(no cookies sent)" : header;
}
}

View File

@@ -0,0 +1,144 @@
package com.ankurm.cors.spec;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* A deliberately small cookie jar that applies the storage and sending rules a browser applies,
* so that "the browser drops this cookie" becomes something you can run instead of something
* you have to believe.
*
* <p>It is a model of two paragraphs of
* <a href="https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis">RFC 6265bis</a>,
* not a browser:
*
* <ul>
* <li><b>&sect;5.5 storage.</b> "If the cookie's {@code same-site-flag} is {@code None} and the
* cookie's {@code secure-only-flag} is false, then abort these steps and ignore the newly
* created cookie entirely." A {@code Set-Cookie} with {@code SameSite=None} and no
* {@code Secure} is not stored, and there is no error anywhere &mdash; the cookie simply never
* exists.</li>
* <li><b>&sect;5.8.3 sending.</b> A cookie whose {@code same-site-flag} is {@code Strict} or
* {@code Lax} is not attached to a cross-site request; {@code Lax} makes an exception for
* top-level safe-method navigations, which a {@code fetch()} from a SPA is not. A cookie with
* no {@code SameSite} attribute is treated as {@code Lax} &mdash; by Chromium-based browsers.
* Firefox has not enabled Lax-by-default on its release channel, so it still treats an absent
* attribute as unrestricted. This jar models the Chromium behaviour, because that is the one
* a deployment has to survive.</li>
* </ul>
*
* <p>Feeding the real {@code Set-Cookie} headers the application emits through this jar is what
* turns the SameSite section of the article into evidence. See
* <a href="../../../../docs/07-samesite.md">docs/07-samesite.md</a>.
*/
public final class SpecCookieJar {
/** How the request was initiated, which is what decides the same-site check. */
public enum Context {
/** Same registrable domain as the cookie's origin. */
SAME_SITE,
/** A top-level navigation (clicking a link, a form GET) from another site. */
CROSS_SITE_TOP_LEVEL_NAVIGATION,
/** An XHR/fetch/subresource load from another site. This is the SPA case. */
CROSS_SITE_SUBRESOURCE
}
/** A stored cookie, after the attributes have been parsed. */
public record StoredCookie(String name, String value, String sameSite, boolean secure,
boolean httpOnly, boolean partitioned) {
}
private final Map<String, StoredCookie> jar = new LinkedHashMap<>();
private final List<String> rejections = new ArrayList<>();
/**
* Apply one {@code Set-Cookie} header. Returns the reason it was rejected, or {@code null}
* when it was stored.
*/
public String setCookie(String header, boolean secureContext) {
String[] parts = header.split(";");
String[] nv = parts[0].split("=", 2);
String name = nv[0].trim();
String value = nv.length > 1 ? nv[1].trim() : "";
String sameSite = null;
boolean secure = false;
boolean httpOnly = false;
boolean partitioned = false;
for (int i = 1; i < parts.length; i++) {
String attribute = parts[i].trim();
String lower = attribute.toLowerCase(Locale.ROOT);
if (lower.startsWith("samesite=")) {
sameSite = attribute.substring("samesite=".length()).trim();
}
else if (lower.equals("secure")) {
secure = true;
}
else if (lower.equals("httponly")) {
httpOnly = true;
}
else if (lower.equals("partitioned")) {
partitioned = true;
}
}
// RFC 6265bis 5.5: the Secure attribute is only honoured from a trustworthy origin.
// HTTPS qualifies; so does http://localhost in every current browser, which is why
// this parameter is called secureContext rather than https.
boolean secureHonoured = secure && secureContext;
// RFC 6265bis 5.5: SameSite=None without an effective Secure is ignored entirely.
if ("None".equalsIgnoreCase(sameSite) && !secureHonoured) {
String reason = "REJECTED " + name + ": SameSite=None " + (secure
? "with Secure, but the origin is not trustworthy so Secure is not honoured"
: "and no Secure attribute") + " - RFC 6265bis 5.5";
this.rejections.add(reason);
return reason;
}
secure = secureHonoured;
// Partitioned (CHIPS) requires Secure as well.
if (partitioned && !secure) {
String reason = "REJECTED " + name + ": Partitioned without Secure";
this.rejections.add(reason);
return reason;
}
this.jar.put(name, new StoredCookie(name, value, sameSite, secure, httpOnly, partitioned));
return null;
}
/** The {@code Cookie} header a browser would send for a request made in this context. */
public String cookieHeaderFor(Context context, boolean safeMethod) {
StringBuilder sb = new StringBuilder();
for (StoredCookie cookie : this.jar.values()) {
if (!willSend(cookie, context, safeMethod)) {
continue;
}
sb.append(sb.isEmpty() ? "" : "; ").append(cookie.name()).append('=').append(cookie.value());
}
return sb.toString();
}
private static boolean willSend(StoredCookie cookie, Context context, boolean safeMethod) {
// No SameSite attribute means Lax in Chromium-based browsers, which is where the modern
// default bites. Firefox's release channel still treats an absent attribute as
// unrestricted; modelling the stricter of the two is the useful choice.
String effective = (cookie.sameSite() == null) ? "Lax" : cookie.sameSite();
return switch (context) {
case SAME_SITE -> true;
case CROSS_SITE_TOP_LEVEL_NAVIGATION ->
effective.equalsIgnoreCase("None") || (effective.equalsIgnoreCase("Lax") && safeMethod);
case CROSS_SITE_SUBRESOURCE -> effective.equalsIgnoreCase("None");
};
}
public List<String> rejections() {
return List.copyOf(this.rejections);
}
public Map<String, StoredCookie> stored() {
return Map.copyOf(this.jar);
}
}

View File

@@ -0,0 +1,67 @@
package com.ankurm.cors.web;
import java.util.LinkedHashMap;
import java.util.Map;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
/**
* The API the imaginary single-page application talks to.
*
* <p>{@code /api/boom} exists to demonstrate one specific failure: a request that passed the
* CORS check and then threw. Tomcat re-dispatches to {@code /error}, and what the browser
* reports is not the 500 &mdash; see
* <a href="../../../../docs/05-the-error-dispatch.md">docs/05-the-error-dispatch.md</a>.
*/
@RestController
public class ApiController {
@GetMapping("/api/data")
public Map<String, Object> data(HttpServletRequest request) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("method", request.getMethod());
body.put("origin", String.valueOf(request.getHeader("Origin")));
body.put("cookies", cookieNames(request));
return body;
}
@PostMapping("/api/data")
public Map<String, Object> create(HttpServletRequest request,
@RequestBody(required = false) String body) {
Map<String, Object> out = new LinkedHashMap<>();
out.put("created", true);
out.put("received", body == null ? "" : body);
out.put("cookies", cookieNames(request));
return out;
}
@GetMapping("/api/whoami")
public Map<String, Object> whoami(Authentication authentication, HttpServletRequest request) {
Map<String, Object> out = new LinkedHashMap<>();
out.put("name", authentication == null ? "(none)" : authentication.getName());
out.put("sessionId", request.getSession(false) == null ? "(no session)" : "present");
return out;
}
@GetMapping("/api/boom")
public Map<String, Object> boom() {
throw new IllegalStateException("deliberate failure, so you can watch the CORS headers vanish");
}
private static String cookieNames(HttpServletRequest request) {
if (request.getCookies() == null) {
return "(none)";
}
StringBuilder sb = new StringBuilder();
for (var c : request.getCookies()) {
sb.append(sb.isEmpty() ? "" : ",").append(c.getName());
}
return sb.toString();
}
}

View File

@@ -0,0 +1,73 @@
package com.ankurm.cors.web;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import jakarta.servlet.Filter;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.env.Environment;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.cors.CorsConfigurationSource;
/**
* Prints runtime state that is otherwise invisible: which filters are actually in the chain,
* and which {@code CorsConfigurationSource} beans the context holds and what they are named.
*
* <p>The bean-name question matters more than it looks. {@code CorsConfigurer} resolves the
* source by the bean <em>name</em> {@code corsConfigurationSource}, while the code that decides
* whether to switch CORS on at all looks it up by <em>type</em>. See
* <a href="../../../../docs/02-who-resolves-the-source.md">docs/02-who-resolves-the-source.md</a>.
*
* <p>Delete this controller before shipping anything.
*/
@RestController
public class DiagController {
private final FilterChainProxy proxy;
private final Map<String, CorsConfigurationSource> sources;
private final Environment environment;
public DiagController(@Qualifier("springSecurityFilterChain") Filter springSecurityFilterChain,
Map<String, CorsConfigurationSource> sources, Environment environment) {
this.proxy = (FilterChainProxy) springSecurityFilterChain;
this.sources = sources;
this.environment = environment;
}
@GetMapping("/diag/chain")
public Map<String, Object> chain() {
Map<String, Object> out = new LinkedHashMap<>();
out.put("profiles", List.of(this.environment.getActiveProfiles()));
List<Map<String, Object>> chains = new ArrayList<>();
for (SecurityFilterChain chain : this.proxy.getFilterChains()) {
Map<String, Object> one = new LinkedHashMap<>();
one.put("size", chain.getFilters().size());
List<String> names = new ArrayList<>();
for (Filter filter : chain.getFilters()) {
names.add(filter.getClass().getSimpleName());
}
one.put("filters", names);
chains.add(one);
}
out.put("chains", chains);
return out;
}
@GetMapping("/diag/cors-sources")
public Map<String, Object> corsSources() {
Map<String, Object> out = new LinkedHashMap<>();
Map<String, String> byName = new LinkedHashMap<>();
this.sources.forEach((name, source) -> byName.put(name, source.getClass().getSimpleName()));
out.put("corsConfigurationSourceBeans", byName);
out.put("hasBeanNamedCorsConfigurationSource", this.sources.containsKey("corsConfigurationSource"));
return out;
}
}

View File

@@ -0,0 +1,25 @@
# Every scenario in the article is a profile. The default is `securitysource`, the
# configuration that works, so that a bare `mvn spring-boot:run` starts something sane.
spring:
application:
name: cors-csrf-samesite
profiles:
default: securitysource
server:
port: 8080
servlet:
session:
cookie:
# Boot writes exactly what you put here. It does NOT add `Secure` for you when
# same-site is `none`, which is the whole subject of docs/07-samesite.md. Flip
# SESSION_SAME_SITE / SESSION_SECURE from scripts/scenario-samesite.sh and read the
# emitted Set-Cookie header back.
same-site: ${SESSION_SAME_SITE:lax}
secure: ${SESSION_SECURE:false}
http-only: true
logging:
level:
org.springframework.security.web.csrf: ${CSRF_LOG_LEVEL:INFO}
org.springframework.web.cors: ${CORS_LOG_LEVEL:INFO}

View File

@@ -0,0 +1,221 @@
package com.ankurm.cors;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpHeaders;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Assertions that pin the <em>contract</em>: which status code a browser sees, and whether the
* response carries the header that decides whether the browser will show it. They are written
* against the same profiles the transcripts in {@code docs/output/} use, so a change in Spring
* Security that alters any of this breaks a test rather than a paragraph.
*
* <p>These run through {@code MockMvc} with {@code springSecurityFilterChain} applied. That
* exercises the filter chain, which is the layer under test; it does <em>not</em> exercise the
* container's error dispatch, which is why the {@code /error} finding is verified by the
* transcripts in {@code docs/output/09-error-dispatch.txt} rather than here. Noted rather than
* hidden: it is a real limit of this test setup.
*/
class CorsContractTests {
private static MockMvcTester tester(WebApplicationContext context) {
MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(context)
.apply(org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers
.springSecurity())
.build();
return MockMvcTester.create(mockMvc);
}
@Nested
@SpringBootTest
@ActiveProfiles("mvconly")
@DisplayName("CORS on the MVC layer only")
class MvcOnly {
@Autowired
WebApplicationContext context;
@Test
@DisplayName("the preflight is rejected by authorization, and carries no CORS header")
void preflightIsRejected() {
assertThat(tester(this.context).options().uri("/api/data")
.header(HttpHeaders.ORIGIN, "https://spa.example.com")
.header("Access-Control-Request-Method", "POST"))
.hasStatus(401)
.doesNotContainHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN);
}
@Test
@DisplayName("no CorsFilter is in the chain")
void noCorsFilter() {
assertThat(chainClassNames(this.context)).doesNotContain("CorsFilter");
}
}
@Nested
@SpringBootTest
@ActiveProfiles("mvcbridge")
@DisplayName("the same MVC configuration plus .cors(withDefaults())")
class MvcBridge {
@Autowired
WebApplicationContext context;
@Test
@DisplayName("the preflight is answered by CorsFilter with the MVC configuration")
void preflightSucceeds() {
assertThat(tester(this.context).options().uri("/api/data")
.header(HttpHeaders.ORIGIN, "https://spa.example.com")
.header("Access-Control-Request-Method", "POST"))
.hasStatus(200)
.hasHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "https://spa.example.com");
}
@Test
@DisplayName("MVC's CorsRegistration supplies a max-age default that CorsConfiguration does not")
void mvcSuppliesMaxAge() {
assertThat(tester(this.context).options().uri("/api/data")
.header(HttpHeaders.ORIGIN, "https://spa.example.com")
.header("Access-Control-Request-Method", "POST"))
.hasHeader(HttpHeaders.ACCESS_CONTROL_MAX_AGE, "1800");
}
@Test
@DisplayName("CorsFilter sits between HeaderWriterFilter and LogoutFilter")
void corsFilterPosition() {
var names = chainClassNames(this.context);
assertThat(names).contains("CorsFilter");
assertThat(names.indexOf("CorsFilter")).isGreaterThan(names.indexOf("HeaderWriterFilter"));
assertThat(names.indexOf("CorsFilter")).isLessThan(names.indexOf("AuthorizationFilter"));
}
}
@Nested
@SpringBootTest
@ActiveProfiles("securitysource")
@DisplayName("a bean named corsConfigurationSource")
class SecuritySource {
@Autowired
WebApplicationContext context;
@Test
@DisplayName("no max-age is emitted, so every request re-runs the preflight")
void noMaxAge() {
assertThat(tester(this.context).options().uri("/api/data")
.header(HttpHeaders.ORIGIN, "https://spa.example.com")
.header("Access-Control-Request-Method", "POST"))
.hasStatus(200)
.doesNotContainHeader(HttpHeaders.ACCESS_CONTROL_MAX_AGE);
}
@Test
@DisplayName("origin, method and header rejections are indistinguishable to the client")
void threeRejectionsLookIdentical() throws Exception {
var badOrigin = tester(this.context).options().uri("/api/data")
.header(HttpHeaders.ORIGIN, "https://evil.example.com")
.header("Access-Control-Request-Method", "POST").exchange();
var badMethod = tester(this.context).options().uri("/api/data")
.header(HttpHeaders.ORIGIN, "https://spa.example.com")
.header("Access-Control-Request-Method", "DELETE").exchange();
var badHeader = tester(this.context).options().uri("/api/data")
.header(HttpHeaders.ORIGIN, "https://spa.example.com")
.header("Access-Control-Request-Method", "POST")
.header("Access-Control-Request-Headers", "authorization").exchange();
assertThat(badOrigin.getResponse().getStatus()).isEqualTo(403);
assertThat(badMethod.getResponse().getStatus()).isEqualTo(403);
assertThat(badHeader.getResponse().getStatus()).isEqualTo(403);
assertThat(badOrigin.getResponse().getContentAsString()).isEqualTo("Invalid CORS request");
assertThat(badMethod.getResponse().getContentAsString())
.isEqualTo(badOrigin.getResponse().getContentAsString());
assertThat(badHeader.getResponse().getContentAsString())
.isEqualTo(badOrigin.getResponse().getContentAsString());
}
@Test
@DisplayName("an unauthenticated request still carries the CORS header, so the SPA can read the 401")
void unauthenticatedStillCarriesCorsHeader() {
assertThat(tester(this.context).get().uri("/api/data")
.header(HttpHeaders.ORIGIN, "https://spa.example.com"))
.hasStatus(401)
.hasHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "https://spa.example.com");
}
@Test
@DisplayName("a path outside the registered pattern gets no CORS header at all")
void outsideThePatternGetsNothing() {
assertThat(tester(this.context).get().uri("/nope")
.header(HttpHeaders.ORIGIN, "https://spa.example.com"))
.doesNotContainHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN);
}
}
@Nested
@SpringBootTest
@ActiveProfiles("misnamed")
@DisplayName("the right type under the wrong bean name")
class Misnamed {
@Autowired
WebApplicationContext context;
@Test
@DisplayName("the context starts and CorsFilter is in the chain")
void itStarts() {
assertThat(chainClassNames(this.context)).contains("CorsFilter");
}
@Test
@DisplayName("the preflight returns 200 with no CORS headers - the most confusing state there is")
void twoHundredWithNothing() {
assertThat(tester(this.context).options().uri("/api/data")
.header(HttpHeaders.ORIGIN, "https://spa.example.com")
.header("Access-Control-Request-Method", "POST"))
.hasStatus(200)
.doesNotContainHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN);
}
}
@Nested
@SpringBootTest
@ActiveProfiles("twosources")
@DisplayName("two UrlBasedCorsConfigurationSource beans")
class TwoSources {
@Autowired
WebApplicationContext context;
@Test
@DisplayName("CORS is configured anyway, and the bean NAME decides which one wins")
void nameWins() {
assertThat(tester(this.context).options().uri("/api/data")
.header(HttpHeaders.ORIGIN, "https://spa.example.com")
.header("Access-Control-Request-Method", "POST"))
.hasStatus(200);
assertThat(tester(this.context).options().uri("/api/data")
.header(HttpHeaders.ORIGIN, "https://admin.example.com")
.header("Access-Control-Request-Method", "POST"))
.hasStatus(403);
}
}
private static java.util.List<String> chainClassNames(WebApplicationContext context) {
var proxy = (org.springframework.security.web.FilterChainProxy) context
.getBean("springSecurityFilterChain");
return proxy.getFilterChains().get(proxy.getFilterChains().size() - 1).getFilters().stream()
.map((filter) -> filter.getClass().getSimpleName())
.toList();
}
}

View File

@@ -0,0 +1,193 @@
package com.ankurm.cors;
import java.util.List;
import com.ankurm.cors.spec.SpecCookieJar;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpHeaders;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import static org.assertj.core.api.Assertions.assertThat;
/** CSRF-for-SPAs behaviour, and the cookie rules that decide whether the token ever arrives. */
class CsrfAndCookieTests {
private static MockMvcTester tester(WebApplicationContext context) {
MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(context)
.apply(org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers
.springSecurity())
.build();
return MockMvcTester.create(mockMvc);
}
@Nested
@SpringBootTest
@ActiveProfiles("csrfnaive")
@DisplayName("CookieCsrfTokenRepository.withHttpOnlyFalse() on its own")
class Naive {
@Autowired
WebApplicationContext context;
@Test
@DisplayName("the bootstrap GET sets no cookie, because the token is deferred")
void bootstrapGetSetsNoCookie() {
var result = tester(this.context).get().uri("/api/data")
.with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
.user("alice"))
.exchange();
assertThat(result.getResponse().getCookie("XSRF-TOKEN")).isNull();
}
}
@Nested
@SpringBootTest
@ActiveProfiles("csrfspa")
@DisplayName("csrf.spa()")
class Spa {
@Autowired
WebApplicationContext context;
@Test
@DisplayName("the bootstrap GET does set the cookie, because spa() resolves the token eagerly")
void bootstrapGetSetsCookie() {
var result = tester(this.context).get().uri("/api/data")
.with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
.user("alice"))
.exchange();
var cookie = result.getResponse().getCookie("XSRF-TOKEN");
assertThat(cookie).isNotNull();
// The mechanism: spa() installs a handler whose XOR delegate has a null
// csrfRequestAttributeName, so CsrfTokenRequestAttributeHandler.handle falls back
// to token.getParameterName() for the attribute key - and calling that method on
// the SupplierCsrfToken is what dereferences the deferred token.
assertThat(cookie.getValue()).isNotEmpty();
}
@Test
@DisplayName("the cookie carries no SameSite and no Secure attribute")
void cookieHasNoSameSite() {
var result = tester(this.context).get().uri("/api/data")
.with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
.user("alice"))
.exchange();
var cookie = result.getResponse().getCookie("XSRF-TOKEN");
assertThat(cookie).isNotNull();
assertThat(cookie.getSecure()).isFalse();
assertThat(cookie.getAttribute("SameSite")).isNull();
}
@Test
@DisplayName("the raw cookie value works in the header, which is the whole point of spa()")
void rawCookieValueIsAccepted() {
var tester = tester(this.context);
var bootstrap = tester.get().uri("/api/data")
.with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
.user("alice"))
.exchange();
var cookie = bootstrap.getResponse().getCookie("XSRF-TOKEN");
assertThat(tester.post().uri("/api/data")
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{}")
.cookie(cookie)
.header("X-XSRF-TOKEN", cookie.getValue())
.with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
.user("alice")))
.hasStatus(200);
}
}
@Nested
@SpringBootTest
@ActiveProfiles("spaorder")
@DisplayName("csrfTokenRepository(..) before spa()")
class Ordering {
@Autowired
WebApplicationContext context;
@Test
@DisplayName("the custom repository is discarded and the default cookie name comes back")
void customRepositoryIsDiscarded() {
var result = tester(this.context).get().uri("/api/data")
.with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
.user("alice"))
.exchange();
assertThat(result.getResponse().getCookie("MY-CSRF")).isNull();
assertThat(result.getResponse().getCookie("XSRF-TOKEN")).isNotNull();
}
}
@Nested
@DisplayName("SpecCookieJar - the storage and sending rules a browser applies")
class Spec {
@Test
@DisplayName("SameSite=None without Secure is ignored entirely")
void noneWithoutSecureIsDropped() {
SpecCookieJar jar = new SpecCookieJar();
assertThat(jar.setCookie("XSRF-TOKEN=t; Path=/; SameSite=None", true))
.contains("RFC 6265bis");
assertThat(jar.stored()).isEmpty();
}
@Test
@DisplayName("Secure is not honoured from an untrustworthy origin, so None+Secure is dropped over plain http")
void secureNeedsATrustworthyOrigin() {
SpecCookieJar jar = new SpecCookieJar();
assertThat(jar.setCookie("XSRF-TOKEN=t; Path=/; Secure; SameSite=None", false)).isNotNull();
assertThat(jar.stored()).isEmpty();
}
@Test
@DisplayName("a cookie with no SameSite attribute behaves as Lax and is not sent on a cross-site fetch")
void absentSameSiteIsLax() {
SpecCookieJar jar = new SpecCookieJar();
assertThat(jar.setCookie("XSRF-TOKEN=t; Path=/", true)).isNull();
assertThat(jar.cookieHeaderFor(SpecCookieJar.Context.SAME_SITE, true)).isEqualTo("XSRF-TOKEN=t");
assertThat(jar.cookieHeaderFor(SpecCookieJar.Context.CROSS_SITE_SUBRESOURCE, false)).isEmpty();
}
@Test
@DisplayName("Lax is sent on a cross-site top-level navigation but not on a cross-site fetch")
void laxNavigationException() {
SpecCookieJar jar = new SpecCookieJar();
jar.setCookie("JSESSIONID=s; Path=/; SameSite=Lax", true);
assertThat(jar.cookieHeaderFor(SpecCookieJar.Context.CROSS_SITE_TOP_LEVEL_NAVIGATION, true))
.isEqualTo("JSESSIONID=s");
assertThat(jar.cookieHeaderFor(SpecCookieJar.Context.CROSS_SITE_SUBRESOURCE, false)).isEmpty();
}
@Test
@DisplayName("only Secure + SameSite=None survives to a cross-site fetch")
void onlyNoneSecureSurvives() {
SpecCookieJar jar = new SpecCookieJar();
for (String header : List.of("a=1; Path=/", "b=2; Path=/; SameSite=Lax",
"c=3; Path=/; SameSite=Strict", "d=4; Path=/; Secure; SameSite=None")) {
jar.setCookie(header, true);
}
assertThat(jar.cookieHeaderFor(SpecCookieJar.Context.CROSS_SITE_SUBRESOURCE, false))
.isEqualTo("d=4");
}
@Test
@DisplayName("Partitioned without Secure is rejected too")
void partitionedNeedsSecure() {
SpecCookieJar jar = new SpecCookieJar();
assertThat(jar.setCookie("x=1; Path=/; Partitioned", true)).isNotNull();
}
}
@SuppressWarnings("unused")
private static final HttpHeaders UNUSED = null;
}