From cad813e1aee4bae70645f3716349c6348c945ac4 Mon Sep 17 00:00:00 2001 From: Ankur Mhatre Date: Fri, 28 Aug 2026 09:33:22 +0530 Subject: [PATCH] Add the cors-csrf module --- README.md | 34 +- cors-csrf/README.md | 117 ++++++ cors-csrf/docs/01-two-layers.md | 82 ++++ cors-csrf/docs/02-who-resolves-the-source.md | 99 +++++ cors-csrf/docs/03-three-identical-403s.md | 79 ++++ cors-csrf/docs/04-preflight-handlers.md | 73 ++++ cors-csrf/docs/05-the-error-dispatch.md | 79 ++++ cors-csrf/docs/06-csrf-for-spas.md | 114 ++++++ cors-csrf/docs/07-samesite.md | 111 ++++++ cors-csrf/docs/08-debugging-recipes.md | 77 ++++ cors-csrf/docs/output/01-mvc-only.txt | 58 +++ cors-csrf/docs/output/02-mvc-bridge.txt | 56 +++ cors-csrf/docs/output/03-security-source.txt | 36 ++ .../docs/output/04-three-identical-403s.txt | 47 +++ cors-csrf/docs/output/05-misnamed-bean.txt | 39 ++ cors-csrf/docs/output/06-two-sources.txt | 44 +++ .../docs/output/07-wildcard-credentials.txt | 41 ++ cors-csrf/docs/output/08-csrf-naive.txt | 54 +++ cors-csrf/docs/output/09-error-dispatch.txt | 25 ++ cors-csrf/docs/output/10-csrf-spa.txt | 47 +++ cors-csrf/docs/output/11-spa-ordering.txt | 24 ++ cors-csrf/docs/output/12-samesite.txt | 59 +++ cors-csrf/docs/output/13-tests.txt | 34 ++ cors-csrf/pom.xml | 55 +++ cors-csrf/scripts/preflight.sh | 25 ++ cors-csrf/scripts/run-all.sh | 355 ++++++++++++++++++ cors-csrf/scripts/run.sh | 37 ++ cors-csrf/scripts/stop.sh | 11 + .../com/ankurm/cors/CorsCsrfApplication.java | 24 ++ .../ankurm/cors/config/CsrfNaiveConfig.java | 58 +++ .../com/ankurm/cors/config/CsrfSpaConfig.java | 59 +++ .../cors/config/CsrfSpaCrossSiteConfig.java | 69 ++++ .../cors/config/CsrfSpaOrderConfig.java | 60 +++ .../cors/config/ErrorDispatchConfig.java | 39 ++ .../cors/config/MisnamedSourceConfig.java | 56 +++ .../cors/config/MvcBridgeSecurityConfig.java | 40 ++ .../com/ankurm/cors/config/MvcCorsConfig.java | 34 ++ .../cors/config/MvcOnlySecurityConfig.java | 38 ++ .../cors/config/SecuritySourceConfig.java | 55 +++ .../ankurm/cors/config/TwoSourcesConfig.java | 65 ++++ .../java/com/ankurm/cors/config/Users.java | 18 + .../config/WildcardCredentialsConfig.java | 55 +++ .../ankurm/cors/spec/CookieSpecReport.java | 46 +++ .../com/ankurm/cors/spec/SpecCookieJar.java | 144 +++++++ .../com/ankurm/cors/web/ApiController.java | 67 ++++ .../com/ankurm/cors/web/DiagController.java | 73 ++++ cors-csrf/src/main/resources/application.yml | 25 ++ .../com/ankurm/cors/CorsContractTests.java | 221 +++++++++++ .../com/ankurm/cors/CsrfAndCookieTests.java | 193 ++++++++++ 49 files changed, 3338 insertions(+), 13 deletions(-) create mode 100644 cors-csrf/README.md create mode 100644 cors-csrf/docs/01-two-layers.md create mode 100644 cors-csrf/docs/02-who-resolves-the-source.md create mode 100644 cors-csrf/docs/03-three-identical-403s.md create mode 100644 cors-csrf/docs/04-preflight-handlers.md create mode 100644 cors-csrf/docs/05-the-error-dispatch.md create mode 100644 cors-csrf/docs/06-csrf-for-spas.md create mode 100644 cors-csrf/docs/07-samesite.md create mode 100644 cors-csrf/docs/08-debugging-recipes.md create mode 100644 cors-csrf/docs/output/01-mvc-only.txt create mode 100644 cors-csrf/docs/output/02-mvc-bridge.txt create mode 100644 cors-csrf/docs/output/03-security-source.txt create mode 100644 cors-csrf/docs/output/04-three-identical-403s.txt create mode 100644 cors-csrf/docs/output/05-misnamed-bean.txt create mode 100644 cors-csrf/docs/output/06-two-sources.txt create mode 100644 cors-csrf/docs/output/07-wildcard-credentials.txt create mode 100644 cors-csrf/docs/output/08-csrf-naive.txt create mode 100644 cors-csrf/docs/output/09-error-dispatch.txt create mode 100644 cors-csrf/docs/output/10-csrf-spa.txt create mode 100644 cors-csrf/docs/output/11-spa-ordering.txt create mode 100644 cors-csrf/docs/output/12-samesite.txt create mode 100644 cors-csrf/docs/output/13-tests.txt create mode 100644 cors-csrf/pom.xml create mode 100755 cors-csrf/scripts/preflight.sh create mode 100755 cors-csrf/scripts/run-all.sh create mode 100755 cors-csrf/scripts/run.sh create mode 100755 cors-csrf/scripts/stop.sh create mode 100644 cors-csrf/src/main/java/com/ankurm/cors/CorsCsrfApplication.java create mode 100644 cors-csrf/src/main/java/com/ankurm/cors/config/CsrfNaiveConfig.java create mode 100644 cors-csrf/src/main/java/com/ankurm/cors/config/CsrfSpaConfig.java create mode 100644 cors-csrf/src/main/java/com/ankurm/cors/config/CsrfSpaCrossSiteConfig.java create mode 100644 cors-csrf/src/main/java/com/ankurm/cors/config/CsrfSpaOrderConfig.java create mode 100644 cors-csrf/src/main/java/com/ankurm/cors/config/ErrorDispatchConfig.java create mode 100644 cors-csrf/src/main/java/com/ankurm/cors/config/MisnamedSourceConfig.java create mode 100644 cors-csrf/src/main/java/com/ankurm/cors/config/MvcBridgeSecurityConfig.java create mode 100644 cors-csrf/src/main/java/com/ankurm/cors/config/MvcCorsConfig.java create mode 100644 cors-csrf/src/main/java/com/ankurm/cors/config/MvcOnlySecurityConfig.java create mode 100644 cors-csrf/src/main/java/com/ankurm/cors/config/SecuritySourceConfig.java create mode 100644 cors-csrf/src/main/java/com/ankurm/cors/config/TwoSourcesConfig.java create mode 100644 cors-csrf/src/main/java/com/ankurm/cors/config/Users.java create mode 100644 cors-csrf/src/main/java/com/ankurm/cors/config/WildcardCredentialsConfig.java create mode 100644 cors-csrf/src/main/java/com/ankurm/cors/spec/CookieSpecReport.java create mode 100644 cors-csrf/src/main/java/com/ankurm/cors/spec/SpecCookieJar.java create mode 100644 cors-csrf/src/main/java/com/ankurm/cors/web/ApiController.java create mode 100644 cors-csrf/src/main/java/com/ankurm/cors/web/DiagController.java create mode 100644 cors-csrf/src/main/resources/application.yml create mode 100644 cors-csrf/src/test/java/com/ankurm/cors/CorsContractTests.java create mode 100644 cors-csrf/src/test/java/com/ankurm/cors/CsrfAndCookieTests.java diff --git a/README.md b/README.md index cbf49bc..654834d 100644 --- a/README.md +++ b/README.md @@ -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 | | [`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 | +| [`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 -`Authentication` gets into `SecurityContextHolder` in the first place and in what order; -`context-propagation` is about whether it survives leaving the request thread; `method-security` -reads it back on whatever thread it ends up on. An `@Async` method carrying `@PreAuthorize` fails -with `AuthenticationCredentialsNotFoundException` for reasons that belong to the second module, -not the third — and a custom authentication filter that never populated the context in the first +They are related more closely than they look. `filter-chain` is about how an `Authentication` +gets into `SecurityContextHolder` in the first place and in what order; `context-propagation` is +about whether it survives leaving the request thread; `method-security` reads it back on whatever +thread it ends up on. An `@Async` method carrying `@PreAuthorize` fails with +`AuthenticationCredentialsNotFoundException` for reasons that belong to the second module, not +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. +`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 -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** manages. Versions were taken from `maven-metadata.xml` on Maven Central rather than from release announcements. `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 -that is a real servlet application: it inherits `spring-boot-starter-parent` and runs on Tomcat, -because the thing it demonstrates only exists inside a servlet container. +still a preview API on JDK 25. `method-security` does not. `filter-chain` and `cors-csrf` are +real servlet applications: they inherit `spring-boot-starter-parent` and run on Tomcat, because +the things they demonstrate only exist inside a servlet container. ## Running a module ```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/ mvn test # just the assertions ``` -`filter-chain` also has `./scripts/run.sh ` and `./scripts/stop.sh`, because its -scenarios are a running web application rather than a `main()` method. +`filter-chain` and `cors-csrf` also have `./scripts/run.sh ` and `./scripts/stop.sh`, +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 diff --git a/cors-csrf/README.md b/cors-csrf/README.md new file mode 100644 index 0000000..c291e58 --- /dev/null +++ b/cors-csrf/README.md @@ -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 `` 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 diff --git a/cors-csrf/docs/01-two-layers.md b/cors-csrf/docs/01-two-layers.md new file mode 100644 index 0000000..abd59f4 --- /dev/null +++ b/cors-csrf/docs/01-two-layers.md @@ -0,0 +1,82 @@ +# 1. Two layers, one word + +*Next: [2. Who resolves the CorsConfigurationSource](02-who-resolves-the-source.md)* + +A Spring Boot application can be told about CORS in two entirely separate places, and the two +places do not talk to each other unless you make them. + +**The MVC layer.** `WebMvcConfigurer.addCorsMappings(..)` and `@CrossOrigin` register a +`CorsConfiguration` with Spring MVC's handler mappings. It is consulted inside +`DispatcherServlet`, when the request is being matched to a handler method. + +**The security layer.** `HttpSecurity.cors(..)` puts a `org.springframework.web.filter.CorsFilter` +into the security filter chain. It runs at order **1000** — between `HeaderWriterFilter` +(900) and `CsrfFilter` (1100), and a long way above `AuthorizationFilter` (4200). + +The security filter chain runs to completion before `DispatcherServlet` is ever entered. So if +the security chain rejects a request, MVC's CORS configuration is not merely ignored: the code +that reads it never executes. + +## Why that specifically breaks preflights + +A CORS preflight is not a special protocol. It is an ordinary `OPTIONS` request carrying two +headers: + +``` +OPTIONS /api/data HTTP/1.1 +Origin: https://spa.example.com +Access-Control-Request-Method: POST +``` + +The browser sends it **without credentials** — no cookies, no `Authorization` header, by +design. Against `anyRequest().authenticated()` that request is anonymous, and anonymous requests +are denied. The rejection happens at order 4200, three thousand two hundred slots before +`DispatcherServlet` and about four thousand before your `addCorsMappings` call matters. + +[`docs/output/01-mvc-only.txt`](output/01-mvc-only.txt) is that state: an eleven-filter chain +with no `CorsFilter` in it, and a preflight answered `401`. + +## The fix is one line, and it is not on the MVC layer + +```java +http.cors(Customizer.withDefaults()) +``` + +That is [`MvcBridgeSecurityConfig`](../src/main/java/com/ankurm/cors/config/MvcBridgeSecurityConfig.java), +and [`docs/output/02-mvc-bridge.txt`](output/02-mvc-bridge.txt) is the identical application +answering `200`. The MVC configuration was fine all along. Nothing was reading it. + +`CorsFilter` short-circuits every preflight it sees: + +```java +boolean isValid = this.processor.processRequest(corsConfiguration, request, response); +if (!isValid || CorsUtils.isPreFlightRequest(request)) { + return; // the chain stops here +} +filterChain.doFilter(request, response); +``` + +Read that `if` carefully, because chapter 2 turns on it: the filter returns on **every** +preflight, whether or not it found a configuration to apply. + +## One thing that changes when you move the configuration + +Moving CORS from `addCorsMappings` to a `CorsConfigurationSource` bean is not a pure +relocation. Compare the two transcripts: + +``` +02-mvc-bridge.txt Access-Control-Max-Age: 1800 +03-security-source.txt (nothing) +``` + +`CorsRegistration` — the builder behind `addCorsMappings` — defaults `maxAge` to +1800 seconds. A bare `CorsConfiguration` leaves it `null`, and a preflight response with no +`Access-Control-Max-Age` is not cached, so the browser preflights **every single cross-origin +call**. Two round trips instead of one, forever, with nothing in any log to suggest it. + +```java +configuration.setMaxAge(1800L); +``` + +--- +*Next: [2. Who resolves the CorsConfigurationSource](02-who-resolves-the-source.md)* diff --git a/cors-csrf/docs/02-who-resolves-the-source.md b/cors-csrf/docs/02-who-resolves-the-source.md new file mode 100644 index 0000000..89518f6 --- /dev/null +++ b/cors-csrf/docs/02-who-resolves-the-source.md @@ -0,0 +1,99 @@ +# 2. Who resolves the `CorsConfigurationSource` + +*Prev: [1. Two layers, one word](01-two-layers.md) · Next: [3. The three identical 403s](03-three-identical-403s.md)* + +There are two lookups involved in getting a CORS configuration into the filter chain, and they +disagree about what they are looking for. One searches by **type**. The other searches by +**name**. + +## Lookup 1: should the CORS configurer run at all? + +`HttpSecurityConfiguration.applyCorsIfAvailable(HttpSecurity)`, disassembled from +`spring-security-config` 7.1.1: + +``` + 4: ldc // class org/springframework/web/cors/UrlBasedCorsConfigurationSource + 6: invokeinterface // ApplicationContext.getBeanNamesForType:(Ljava/lang/Class;)[Ljava/lang/String; +11: arraylength +12: ifle 23 +19: invokevirtual // HttpSecurity.cors:(Customizer)HttpSecurity +23: return +``` + +By type, and the test is `ifle` — "branch if less than or equal to zero". One bean is +enough. So is five. + +> The reference documentation says: *"If you have more than one `CorsConfigurationSource` bean, +> Spring Security won't automatically configure CORS support for you, because it cannot decide +> which one to use."* That is not what 7.1.1 does. +> [`docs/output/06-two-sources.txt`](output/06-two-sources.txt) has two such beans, CORS +> configured, and one of them serving traffic. + +## Lookup 2: which source does the configurer use? + +`CorsConfigurer.getCorsConfigurationSource(ApplicationContext)`: + +``` + 7: ldc // String corsConfigurationSource + 9: invokeinterface // ApplicationContext.containsBeanDefinition:(Ljava/lang/String;)Z +20: ldc // String corsConfigurationSource +24: invokeinterface // ApplicationContext.getBean:(String,Class)Object +34: invokestatic // MvcCorsFilter.getMvcCorsConfigurationSource:(ApplicationContext)CorsConfigurationSource +``` + +By name. The literal string `corsConfigurationSource`. If no bean definition carries that name, +it falls through to Spring MVC's registrations. (There is a similar name check first, for a +`CorsFilter` bean named `corsFilter`.) + +## The gap between them + +Name a `UrlBasedCorsConfigurationSource` bean anything other than `corsConfigurationSource` and +you land between the two lookups: CORS is switched **on** by the type lookup, and the +configuration you wrote is **ignored** by the name lookup. + +That is the `misnamed` profile, and it produces the worst diagnostic in this whole subject: + +``` +HTTP/1.1 200 +X-Content-Type-Options: nosniff +X-XSS-Protection: 0 +... +``` + +Two hundred. No `Access-Control-Allow-Origin`. The browser blocks the request and reports a CORS +error; your access log shows a successful `OPTIONS`; nothing anywhere is red. + +Why 200 rather than the 401 from chapter 1? Because `CorsFilter` is now in the chain, and its +`if (!isValid || isPreFlightRequest(request)) return;` fires on the second clause. The request +never reaches `AuthorizationFilter`. The only trace is one DEBUG line: + +``` +o.s.web.cors.DefaultCorsProcessor : Skip: no CORS configuration has been provided +``` + +Full transcript: [`docs/output/05-misnamed-bean.txt`](output/05-misnamed-bean.txt). + +## What is in the context that you did not put there + +`/diag/cors-sources` on a stock Boot web application: + +```json +{ "corsConfigurationSourceBeans": { "mvcHandlerMappingIntrospector": "HandlerMappingIntrospector" } } +``` + +`HandlerMappingIntrospector` implements `CorsConfigurationSource`. It is always there, it is not +a `UrlBasedCorsConfigurationSource`, and it is the object the MVC fallback returns. That is why +the fallback path never throws in a normal application — and why the failure is silent +rather than loud. + +## Rules that follow + +- Name the bean `corsConfigurationSource`. Exactly that. +- If you want several, pass them per chain with `.cors(c -> c.configurationSource(..))`, which + bypasses both lookups. +- `NoSuchBeanDefinitionException: Failed to find a bean that implements + \`CorsConfigurationSource\`` names three fixes and does not mention the fourth one, which is + usually the right one: rename your bean. + +--- +*Prev: [1. Two layers, one word](01-two-layers.md) · Next: [3. The three identical 403s](03-three-identical-403s.md)* diff --git a/cors-csrf/docs/03-three-identical-403s.md b/cors-csrf/docs/03-three-identical-403s.md new file mode 100644 index 0000000..44ba162 --- /dev/null +++ b/cors-csrf/docs/03-three-identical-403s.md @@ -0,0 +1,79 @@ +# 3. The three identical 403s + +*Prev: [2. Who resolves the CorsConfigurationSource](02-who-resolves-the-source.md) · Next: [4. Preflight handlers](04-preflight-handlers.md)* + +`DefaultCorsProcessor` runs three checks on a preflight, in order: origin, method, request +headers. All three failures produce the same thing. + +``` +HTTP/1.1 403 +Vary: Origin +Vary: Access-Control-Request-Method +Vary: Access-Control-Request-Headers + +Invalid CORS request +``` + +Same status, same body, byte for byte, no `Access-Control-*` header to distinguish them. The +assertion in `CorsContractTests.threeRejectionsLookIdentical` compares the three bodies for +equality, so if a future version starts distinguishing them, that test fails. + +The only place the difference exists is a DEBUG log line, and the three are worth memorising +because they are the fastest CORS diagnosis available: + +``` +o.s.web.cors.DefaultCorsProcessor : Reject: 'https://evil.example.com' origin is not allowed +o.s.web.cors.DefaultCorsProcessor : Reject: HTTP 'DELETE' is not allowed +o.s.web.cors.DefaultCorsProcessor : Reject: headers '[authorization]' are not allowed +``` + +Turn them on with: + +```yaml +logging.level.org.springframework.web.cors: DEBUG +``` + +The complete set of messages, read out of the class's constant pool, is five: + +| Message | Meaning | +|---|---| +| `Skip: no CORS configuration has been provided` | The source returned `null` for this path — chapter 2 | +| `Skip: response already contains "Access-Control-Allow-Origin"` | Something upstream already handled it | +| `Reject: origin is malformed` | The `Origin` header did not parse | +| `Reject: '…' origin is not allowed` | | +| `Reject: HTTP '…' is not allowed` | | +| `Reject: headers '[…]' are not allowed` | | + +## Status codes, and what each one means + +Collecting the states this module reproduces: + +| What you see | What it means | +|---|---| +| `401`/`403`, no `Access-Control-*` at all | No `CorsFilter` in the chain. The preflight was judged by authorization — chapter 1 | +| `200`, no `Access-Control-*` | `CorsFilter` is present and found no configuration for this path — chapter 2 | +| `403`, `Invalid CORS request` | `CorsFilter` is present and rejected origin, method or headers — this chapter | +| `200` with `Access-Control-Allow-Origin` | It worked | +| `404`, no `Access-Control-*` | The path is outside the pattern you registered. Common with `/api/**` versus a mis-typed URL | + +The browser reports the same "blocked by CORS policy" for the first four rows. Two of them are +not CORS problems. + +## What a **simple** request does + +Only preflighted requests get intercepted. A simple `GET` runs the whole chain, so an +unauthenticated one returns 401 — **carrying** the CORS header, because `CorsFilter` at +1000 already wrote it before `AuthorizationFilter` at 4200 rejected the request: + +``` +HTTP/1.1 401 +Access-Control-Allow-Origin: https://spa.example.com +Access-Control-Allow-Credentials: true +``` + +That is the good case: the SPA's `fetch` resolves and the code can read `response.status`. It is +also the reason "my POST fails but my GET returns a readable 401" is a coherent bug report and +not a contradiction. + +--- +*Prev: [2. Who resolves the CorsConfigurationSource](02-who-resolves-the-source.md) · Next: [4. Preflight handlers](04-preflight-handlers.md)* diff --git a/cors-csrf/docs/04-preflight-handlers.md b/cors-csrf/docs/04-preflight-handlers.md new file mode 100644 index 0000000..d50369e --- /dev/null +++ b/cors-csrf/docs/04-preflight-handlers.md @@ -0,0 +1,73 @@ +# 4. `PreFlightRequestHandler`, and the wildcard that is not allowed + +*Prev: [3. The three identical 403s](03-three-identical-403s.md) · Next: [5. The /error dispatch](05-the-error-dispatch.md)* + +## `preFlightRequestHandler` + +`CorsConfigurer` in 7.1.1 has a second setter beside `configurationSource`: + +```java +public CorsConfigurer preFlightRequestHandler(PreFlightRequestHandler handler); +``` + +When one is selected, Spring Security registers Spring Framework's `PreFlightRequestFilter` +**before** `CorsFilter` in the chain — `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 — 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 — 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` — 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) · Next: [5. The /error dispatch](05-the-error-dispatch.md)* diff --git a/cors-csrf/docs/05-the-error-dispatch.md b/cors-csrf/docs/05-the-error-dispatch.md new file mode 100644 index 0000000..6158765 --- /dev/null +++ b/cors-csrf/docs/05-the-error-dispatch.md @@ -0,0 +1,79 @@ +# 5. The `/error` dispatch, or why your 403 arrives as a 401 + +*Prev: [4. Preflight handlers](04-preflight-handlers.md) · Next: [6. CSRF for SPAs](06-csrf-for-spas.md)* + +Two of the failures in this module — the wildcard/credentials clash in chapter 4 and the +CSRF rejection in chapter 6 — 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 — + `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) · Next: [6. CSRF for SPAs](06-csrf-for-spas.md)* diff --git a/cors-csrf/docs/06-csrf-for-spas.md b/cors-csrf/docs/06-csrf-for-spas.md new file mode 100644 index 0000000..064823e --- /dev/null +++ b/cors-csrf/docs/06-csrf-for-spas.md @@ -0,0 +1,114 @@ +# 6. CSRF for SPAs, and what `spa()` actually assigns + +*Prev: [5. The /error dispatch](05-the-error-dispatch.md) · Next: [7. SameSite](07-samesite.md)* + +## The recipe that stopped working in 6.0 + +```java +http.csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())) +``` + +Every SPA tutorial written before Spring Security 6 ends with that line. Three separate things go +wrong with it now, and [`docs/output/08-csrf-naive.txt`](output/08-csrf-naive.txt) walks through +all three in one transcript. + +**1. The bootstrap GET sets no cookie.** Since 6.0 the token is *deferred*: `CsrfFilter` puts a +`Supplier` 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 // +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 `
` post keeps the BREACH masking on the hidden field. Both work, from +one configuration. + +## The part nobody documents: why the cookie now appears on the GET + +That `xor.setCsrfRequestAttributeName(null)` looks like a detail. It is the fix for problem 1. + +`CsrfTokenRequestAttributeHandler.handle` wraps the supplier in a `SupplierCsrfToken` and sets +two request attributes. The key for the second one is the configured attribute name — or, +when that is `null`, `csrfToken.getParameterName()`. Calling `getParameterName()` on a +`SupplierCsrfToken` **dereferences the supplier**. The token is generated, the repository saves +it, and the `Set-Cookie` header goes out. + +The eager rendering is a side effect of needing a string for a map key. It is real, it is +load-bearing, and [`docs/output/10-csrf-spa.txt`](output/10-csrf-spa.txt) shows the cookie +arriving on the bootstrap `GET` where `08` showed nothing. + +## `spa()` discards what you configured before it + +Because the two assignments are unconditional: + +```java +.csrf(csrf -> csrf.csrfTokenRepository(myRepository).spa()) // myRepository is gone +.csrf(csrf -> csrf.spa().csrfTokenRepository(myRepository)) // this one wins +``` + +The `spaorder` profile asks for a cookie named `MY-CSRF` and a header named `X-CSRF-TOKEN`; +[`docs/output/11-spa-ordering.txt`](output/11-spa-ordering.txt) shows `XSRF-TOKEN` coming back +instead. Tracked as [spring-security#18718](https://github.com/spring-projects/spring-security/issues/18718). + +## A CSRF rejection does not look like a CSRF rejection + +`CsrfFilter` logs, at DEBUG: + +``` +o.s.security.web.csrf.CsrfFilter : Invalid CSRF token found for http://localhost:8080/api/data +``` + +and hands off to its `AccessDeniedHandler`, which calls `sendError(403)`. What the client +receives, in a Basic-authenticated API with no `/error` chain, is **401 with +`WWW-Authenticate: Basic`** — chapter 5. Every hour spent checking credentials after a +403-that-is-a-401 is spent on the wrong thing. + +Turn on `logging.level.org.springframework.security.web.csrf: DEBUG` before anything else. + +## Do you need CSRF at all? + +If the API authenticates with a `Bearer` token held in memory and never with a cookie, then no: +there is no ambient credential for a third-party page to ride on, and `csrf.disable()` is +correct rather than lazy. If any part of the session lives in a cookie — including a +`HttpOnly` refresh cookie — then yes, and `spa()` is the shortest correct configuration. + +--- +*Prev: [5. The /error dispatch](05-the-error-dispatch.md) · Next: [7. SameSite](07-samesite.md)* diff --git a/cors-csrf/docs/07-samesite.md b/cors-csrf/docs/07-samesite.md new file mode 100644 index 0000000..0e13b7a --- /dev/null +++ b/cors-csrf/docs/07-samesite.md @@ -0,0 +1,111 @@ +# 7. SameSite, `Secure`, and the cookie that is never stored + +*Prev: [6. CSRF for SPAs](06-csrf-for-spas.md) · 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=; Path=/ +Set-Cookie: JSESSIONID=; 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 §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 §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** | — | +| `JSESSIONID=s3; Secure; HttpOnly; SameSite=None` | yes | **yes** | +| `XSRF-TOKEN=t1` (no SameSite) | yes | no | +| `XSRF-TOKEN=t2; SameSite=None` | **no** | — | +| `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` +— 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 — 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) · Next: [8. Debugging recipes](08-debugging-recipes.md)* diff --git a/cors-csrf/docs/08-debugging-recipes.md b/cors-csrf/docs/08-debugging-recipes.md new file mode 100644 index 0000000..63af830 --- /dev/null +++ b/cors-csrf/docs/08-debugging-recipes.md @@ -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 — 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 — no `CorsFilter` | +| Preflight `200`, no CORS headers | Chapter 2 — bean name | +| Preflight `403`, `Invalid CORS request` | Chapter 3 — read the DEBUG line | +| `401` on a request with valid credentials | Chapter 5 — the `/error` dispatch | +| `403` on a POST, `GET` is fine | Chapter 6 — CSRF | +| Cookie visible in DevTools' response, absent from the jar | Chapter 7 — `SameSite=None` with no `Secure` | +| Every request preflights, latency doubled | Chapter 1 — 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 → 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)* diff --git a/cors-csrf/docs/output/01-mvc-only.txt b/cors-csrf/docs/output/01-mvc-only.txt new file mode 100644 index 0000000..6868ca1 --- /dev/null +++ b/cors-csrf/docs/output/01-mvc-only.txt @@ -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=; 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. diff --git a/cors-csrf/docs/output/02-mvc-bridge.txt b/cors-csrf/docs/output/02-mvc-bridge.txt new file mode 100644 index 0000000..4847a9f --- /dev/null +++ b/cors-csrf/docs/output/02-mvc-bridge.txt @@ -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. diff --git a/cors-csrf/docs/output/03-security-source.txt b/cors-csrf/docs/output/03-security-source.txt new file mode 100644 index 0000000..1d59df0 --- /dev/null +++ b/cors-csrf/docs/output/03-security-source.txt @@ -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. diff --git a/cors-csrf/docs/output/04-three-identical-403s.txt b/cors-csrf/docs/output/04-three-identical-403s.txt new file mode 100644 index 0000000..bfa781d --- /dev/null +++ b/cors-csrf/docs/output/04-three-identical-403s.txt @@ -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: + DEBUG --- [cors-csrf-samesite] [nio-8080-exec-N] o.s.web.cors.DefaultCorsProcessor : Reject: 'https://evil.example.com' origin is not allowed + DEBUG --- [cors-csrf-samesite] [nio-8080-exec-N] o.s.web.cors.DefaultCorsProcessor : Reject: HTTP 'DELETE' is not allowed + DEBUG --- [cors-csrf-samesite] [nio-8080-exec-N] o.s.web.cors.DefaultCorsProcessor : Reject: headers '[authorization]' are not allowed + DEBUG --- [cors-csrf-samesite] [nio-8080-exec-N] o.s.web.cors.DefaultCorsProcessor : Reject: 'https://evil.example.com' origin is not allowed diff --git a/cors-csrf/docs/output/05-misnamed-bean.txt b/cors-csrf/docs/output/05-misnamed-bean.txt new file mode 100644 index 0000000..b598b38 --- /dev/null +++ b/cors-csrf/docs/output/05-misnamed-bean.txt @@ -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 + + DEBUG --- [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. diff --git a/cors-csrf/docs/output/06-two-sources.txt b/cors-csrf/docs/output/06-two-sources.txt new file mode 100644 index 0000000..c75d89d --- /dev/null +++ b/cors-csrf/docs/output/06-two-sources.txt @@ -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 + + DEBUG --- [cors-csrf-samesite] [nio-8080-exec-N] o.s.web.cors.DefaultCorsProcessor : Reject: 'https://admin.example.com' origin is not allowed diff --git a/cors-csrf/docs/output/07-wildcard-credentials.txt b/cors-csrf/docs/output/07-wildcard-credentials.txt new file mode 100644 index 0000000..db2b544 --- /dev/null +++ b/cors-csrf/docs/output/07-wildcard-credentials.txt @@ -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=; 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=; 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. diff --git a/cors-csrf/docs/output/08-csrf-naive.txt b/cors-csrf/docs/output/08-csrf-naive.txt new file mode 100644 index 0000000..6541558 --- /dev/null +++ b/cors-csrf/docs/output/08-csrf-naive.txt @@ -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=; 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=; 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" + + DEBUG --- [cors-csrf-samesite] [nio-8080-exec-N] o.s.security.web.csrf.CsrfFilter : Invalid CSRF token found for http://localhost:8080/api/data + DEBUG --- [cors-csrf-samesite] [nio-8080-exec-N] o.s.security.web.csrf.CsrfFilter : Invalid CSRF token found for http://localhost:8080/api/data diff --git a/cors-csrf/docs/output/09-error-dispatch.txt b/cors-csrf/docs/output/09-error-dispatch.txt new file mode 100644 index 0000000..5dfc9ab --- /dev/null +++ b/cors-csrf/docs/output/09-error-dispatch.txt @@ -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=; 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":"","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. diff --git a/cors-csrf/docs/output/10-csrf-spa.txt b/cors-csrf/docs/output/10-csrf-spa.txt new file mode 100644 index 0000000..76743c0 --- /dev/null +++ b/cors-csrf/docs/output/10-csrf-spa.txt @@ -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=; 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=; 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. diff --git a/cors-csrf/docs/output/11-spa-ordering.txt b/cors-csrf/docs/output/11-spa-ordering.txt new file mode 100644 index 0000000..c1bab97 --- /dev/null +++ b/cors-csrf/docs/output/11-spa-ordering.txt @@ -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=; 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. diff --git a/cors-csrf/docs/output/12-samesite.txt b/cors-csrf/docs/output/12-samesite.txt new file mode 100644 index 0000000..192257e --- /dev/null +++ b/cors-csrf/docs/output/12-samesite.txt @@ -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=; Path=/ +Set-Cookie: JSESSIONID=; Path=/; HttpOnly; SameSite=Lax + +## session cookie set to same-site=none, secure=false +Set-Cookie: XSRF-TOKEN=; Path=/ +Set-Cookie: JSESSIONID=; Path=/; HttpOnly; SameSite=None + +## crosssite profile: SameSite=None and Secure on both cookies +Set-Cookie: XSRF-TOKEN=; Path=/; Secure; SameSite=None +Set-Cookie: JSESSIONID=; Path=/; Secure; HttpOnly; SameSite=None + +## crosssite profile with -DOMIT_SECURE=true +Set-Cookie: XSRF-TOKEN=; Path=/; SameSite=None +Set-Cookie: JSESSIONID=; 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.) diff --git a/cors-csrf/docs/output/13-tests.txt b/cors-csrf/docs/output/13-tests.txt new file mode 100644 index 0000000..ee04bc4 --- /dev/null +++ b/cors-csrf/docs/output/13-tests.txt @@ -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 + INFO --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CsrfAndCookieTests$Spa + INFO --- [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 + INFO --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CsrfAndCookieTests$Ordering + INFO --- [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 + INFO --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$MvcOnly + INFO --- [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 + INFO --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$Misnamed + INFO --- [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 + INFO --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$SecuritySource + INFO --- [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 + INFO --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$TwoSources + INFO --- [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 + INFO --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$MvcBridge + INFO --- [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 diff --git a/cors-csrf/pom.xml b/cors-csrf/pom.xml new file mode 100644 index 0000000..3abb3df --- /dev/null +++ b/cors-csrf/pom.xml @@ -0,0 +1,55 @@ + + 4.0.0 + + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + cors-csrf-samesite + 1.0 + jar + + + 25 + UTF-8 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-security-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/cors-csrf/scripts/preflight.sh b/cors-csrf/scripts/preflight.sh new file mode 100755 index 0000000..b62b605 --- /dev/null +++ b/cors-csrf/scripts/preflight.sh @@ -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$//' diff --git a/cors-csrf/scripts/run-all.sh b/cors-csrf/scripts/run-all.sh new file mode 100755 index 0000000..c9de03b --- /dev/null +++ b/cors-csrf/scripts/run-all.sh @@ -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:]+)?//g' \ + -e 's/(JSESSIONID=)[0-9A-F]+/\1/g' \ + -e 's/(XSRF-TOKEN=|MY-CSRF=)[0-9a-f-]{36}/\1/g' \ + -e 's/(X-XSRF-TOKEN: |X-CSRF-TOKEN: )[0-9a-f-]{36}/\1/g' \ + -e '/^(Date|Keep-Alive|Connection|Content-Length|Transfer-Encoding|Expires):/d' \ + -e 's/PID [0-9]+/PID /g' \ + -e 's/in [0-9.]+ seconds \(process running for [0-9.]+\)/in seconds/g' \ + -e 's/ [0-9]+ --- / --- /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 -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 + 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