# `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 `` 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