Add the cors-csrf module
This commit is contained in:
34
README.md
34
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 <profile>` 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 <profile>` 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
|
||||
|
||||
|
||||
117
cors-csrf/README.md
Normal file
117
cors-csrf/README.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# `cors-csrf` — CORS, CSRF and SameSite, reproduced state by state
|
||||
|
||||
Companion project for
|
||||
[**CORS, CSRF and SameSite in Spring Boot 4: The Three Settings Everyone Gets Wrong**](https://ankurm.com/spring-boot-4-cors-csrf-samesite/)
|
||||
on ankurm.com.
|
||||
|
||||
Every broken state the article describes is a Spring profile on this one application, and every
|
||||
transcript under [`docs/output/`](docs/output/) was produced by running it. No browser is
|
||||
required: a CORS preflight is an `OPTIONS` carrying two headers, and `curl` sends those.
|
||||
|
||||
## Versions
|
||||
|
||||
| | Version | Notes |
|
||||
|---|---|---|
|
||||
| JDK | 25 (Temurin 25.0.4.1+1) | current LTS |
|
||||
| Spring Boot | 4.1.1 | inherited as parent, so everything below is Boot-managed |
|
||||
| Spring Framework | 7.0.9 | `CorsFilter`, `DefaultCorsProcessor`, `CorsConfiguration` |
|
||||
| Spring Security | 7.1.1 | `CorsConfigurer`, `CsrfConfigurer.spa()` |
|
||||
| Tomcat | 11.0.24 | |
|
||||
| JUnit Jupiter / AssertJ | Boot-managed | 23 assertions |
|
||||
|
||||
Versions were read from `repo1.maven.org/.../maven-metadata.xml`, not from release
|
||||
announcements. Note that `maven-metadata.xml`'s own `<release>` element pointed at
|
||||
`4.2.0-M1` while this was written; a milestone is not a release.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
./scripts/run.sh securitysource # the configuration that works
|
||||
./scripts/preflight.sh # one preflight, headers printed
|
||||
|
||||
./scripts/run.sh mvconly # the same app with CORS on the MVC layer only
|
||||
./scripts/preflight.sh # → 401
|
||||
|
||||
./scripts/run-all.sh # every scenario, regenerating docs/output/
|
||||
mvn test # just the 23 assertions
|
||||
./scripts/stop.sh
|
||||
```
|
||||
|
||||
The user is `alice` / `password`.
|
||||
|
||||
## Profiles
|
||||
|
||||
| Profile | Configuration | Shows |
|
||||
|---|---|---|
|
||||
| `securitysource` *(default)* | [`SecuritySourceConfig`](src/main/java/com/ankurm/cors/config/SecuritySourceConfig.java) | A bean named `corsConfigurationSource`. This one works |
|
||||
| `mvconly` | [`MvcOnlySecurityConfig`](src/main/java/com/ankurm/cors/config/MvcOnlySecurityConfig.java) | `addCorsMappings` and nothing else — preflight answered `401` |
|
||||
| `mvcbridge` | [`MvcBridgeSecurityConfig`](src/main/java/com/ankurm/cors/config/MvcBridgeSecurityConfig.java) | The same MVC config plus `.cors(withDefaults())` — and MVC's `max-age` default |
|
||||
| `misnamed` | [`MisnamedSourceConfig`](src/main/java/com/ankurm/cors/config/MisnamedSourceConfig.java) | Right type, wrong bean name — preflight answered `200` with no CORS headers |
|
||||
| `twosources` | [`TwoSourcesConfig`](src/main/java/com/ankurm/cors/config/TwoSourcesConfig.java) | Two sources. The docs say CORS is not configured; it is, and the name decides |
|
||||
| `wildcard` | [`WildcardCredentialsConfig`](src/main/java/com/ankurm/cors/config/WildcardCredentialsConfig.java) | `allowedOrigins("*")` with credentials — fails on the request, surfaces as `401` |
|
||||
| `csrfnaive` | [`CsrfNaiveConfig`](src/main/java/com/ankurm/cors/config/CsrfNaiveConfig.java) | The pre-6.0 SPA recipe: no cookie on the GET, 403 on the POST |
|
||||
| `csrfspa` | [`CsrfSpaConfig`](src/main/java/com/ankurm/cors/config/CsrfSpaConfig.java) | `csrf.spa()`, and why the cookie now arrives on the bootstrap GET |
|
||||
| `spaorder` | [`CsrfSpaOrderConfig`](src/main/java/com/ankurm/cors/config/CsrfSpaOrderConfig.java) | `csrfTokenRepository(..)` before `spa()` — silently discarded |
|
||||
| `crosssite` | [`CsrfSpaCrossSiteConfig`](src/main/java/com/ankurm/cors/config/CsrfSpaCrossSiteConfig.java) | `SameSite=None; Secure` on the CSRF cookie |
|
||||
| `errorpermit` | [`ErrorDispatchConfig`](src/main/java/com/ankurm/cors/config/ErrorDispatchConfig.java) | Add-on. Combine with any other profile to see the status code the `/error` dispatch was hiding |
|
||||
|
||||
Add-on profiles combine: `./scripts/run.sh csrfnaive,errorpermit`.
|
||||
|
||||
Three environment settings change behaviour rather than configuration:
|
||||
|
||||
| Setting | Effect |
|
||||
|---|---|
|
||||
| `SESSION_SAME_SITE` / `SESSION_SECURE` | The session cookie's attributes, written straight through by Boot |
|
||||
| `JVM_ARGS=-DOMIT_SECURE=true` | Under `crosssite`, emit `SameSite=None` **without** `Secure` |
|
||||
| `CORS_LOG_LEVEL` / `CSRF_LOG_LEVEL` | `DEBUG` turns on the two log categories that answer almost every question here |
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|---|---|
|
||||
| `GET`/`POST /api/data` | The API the imaginary SPA calls |
|
||||
| `GET /api/whoami` | Who the request authenticated as |
|
||||
| `GET /api/boom` | Throws, so you can watch the error dispatch |
|
||||
| `GET /diag/chain` | The filters `FilterChainProxy` actually holds |
|
||||
| `GET /diag/cors-sources` | Every `CorsConfigurationSource` bean, by name |
|
||||
| `GET /diag/cookie-spec?h=…&secure=…` | Real `Set-Cookie` headers run through the RFC 6265bis rules |
|
||||
|
||||
The `/diag/**` endpoints are permitted without authentication so the scripts can read them.
|
||||
Delete them before shipping.
|
||||
|
||||
## Documentation
|
||||
|
||||
| Chapter | |
|
||||
|---|---|
|
||||
| [01](docs/01-two-layers.md) | Two layers, one word — why MVC CORS does not fix a security-layer rejection |
|
||||
| [02](docs/02-who-resolves-the-source.md) | Who resolves the `CorsConfigurationSource` — by type, then by **name** |
|
||||
| [03](docs/03-three-identical-403s.md) | The three identical 403s, and reading a status code as a diagnosis |
|
||||
| [04](docs/04-preflight-handlers.md) | `PreFlightRequestHandler`, and the wildcard that is not allowed |
|
||||
| [05](docs/05-the-error-dispatch.md) | The `/error` dispatch, or why your 403 arrives as a 401 |
|
||||
| [06](docs/06-csrf-for-spas.md) | CSRF for SPAs, and what `spa()` actually assigns |
|
||||
| [07](docs/07-samesite.md) | SameSite, `Secure`, and the cookie that is never stored |
|
||||
| [08](docs/08-debugging-recipes.md) | Debugging recipes |
|
||||
|
||||
## Captured output
|
||||
|
||||
| File | |
|
||||
|---|---|
|
||||
| [01-mvc-only.txt](docs/output/01-mvc-only.txt) | An 11-filter chain with no `CorsFilter`, and a `401` preflight |
|
||||
| [02-mvc-bridge.txt](docs/output/02-mvc-bridge.txt) | The same app plus one line — `200`, and `Access-Control-Max-Age: 1800` |
|
||||
| [03-security-source.txt](docs/output/03-security-source.txt) | A `corsConfigurationSource` bean — `200`, and **no** max-age |
|
||||
| [04-three-identical-403s.txt](docs/output/04-three-identical-403s.txt) | Three rejections, one response, three DEBUG lines |
|
||||
| [05-misnamed-bean.txt](docs/output/05-misnamed-bean.txt) | `200` with no CORS headers, and `Skip: no CORS configuration has been provided` |
|
||||
| [06-two-sources.txt](docs/output/06-two-sources.txt) | Two sources, CORS configured anyway, the named bean winning |
|
||||
| [07-wildcard-credentials.txt](docs/output/07-wildcard-credentials.txt) | The `IllegalArgumentException`, arriving as a `401` |
|
||||
| [08-csrf-naive.txt](docs/output/08-csrf-naive.txt) | No cookie on the GET, then two rejected POSTs |
|
||||
| [09-error-dispatch.txt](docs/output/09-error-dispatch.txt) | The same failure with `/error` permitted — the `403` reappears |
|
||||
| [10-csrf-spa.txt](docs/output/10-csrf-spa.txt) | `csrf.spa()`: cookie on the GET, raw value accepted in the header |
|
||||
| [11-spa-ordering.txt](docs/output/11-spa-ordering.txt) | A custom repository silently discarded by `spa()` |
|
||||
| [12-samesite.txt](docs/output/12-samesite.txt) | Four sets of real `Set-Cookie` headers, run through the RFC rules |
|
||||
| [13-tests.txt](docs/output/13-tests.txt) | `mvn test` |
|
||||
|
||||
## Related modules
|
||||
|
||||
- [`filter-chain/`](../filter-chain/README.md) — the order numbers this module keeps citing, and the `/error` dispatch in full
|
||||
- [`context-propagation/`](../context-propagation/README.md) — whether the `SecurityContext` survives leaving the request thread
|
||||
- [`method-security/`](../method-security/README.md) — reading that context back
|
||||
82
cors-csrf/docs/01-two-layers.md
Normal file
82
cors-csrf/docs/01-two-layers.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# 1. Two layers, one word
|
||||
|
||||
*Next: [2. Who resolves the CorsConfigurationSource](02-who-resolves-the-source.md)*
|
||||
|
||||
A Spring Boot application can be told about CORS in two entirely separate places, and the two
|
||||
places do not talk to each other unless you make them.
|
||||
|
||||
**The MVC layer.** `WebMvcConfigurer.addCorsMappings(..)` and `@CrossOrigin` register a
|
||||
`CorsConfiguration` with Spring MVC's handler mappings. It is consulted inside
|
||||
`DispatcherServlet`, when the request is being matched to a handler method.
|
||||
|
||||
**The security layer.** `HttpSecurity.cors(..)` puts a `org.springframework.web.filter.CorsFilter`
|
||||
into the security filter chain. It runs at order **1000** — between `HeaderWriterFilter`
|
||||
(900) and `CsrfFilter` (1100), and a long way above `AuthorizationFilter` (4200).
|
||||
|
||||
The security filter chain runs to completion before `DispatcherServlet` is ever entered. So if
|
||||
the security chain rejects a request, MVC's CORS configuration is not merely ignored: the code
|
||||
that reads it never executes.
|
||||
|
||||
## Why that specifically breaks preflights
|
||||
|
||||
A CORS preflight is not a special protocol. It is an ordinary `OPTIONS` request carrying two
|
||||
headers:
|
||||
|
||||
```
|
||||
OPTIONS /api/data HTTP/1.1
|
||||
Origin: https://spa.example.com
|
||||
Access-Control-Request-Method: POST
|
||||
```
|
||||
|
||||
The browser sends it **without credentials** — no cookies, no `Authorization` header, by
|
||||
design. Against `anyRequest().authenticated()` that request is anonymous, and anonymous requests
|
||||
are denied. The rejection happens at order 4200, three thousand two hundred slots before
|
||||
`DispatcherServlet` and about four thousand before your `addCorsMappings` call matters.
|
||||
|
||||
[`docs/output/01-mvc-only.txt`](output/01-mvc-only.txt) is that state: an eleven-filter chain
|
||||
with no `CorsFilter` in it, and a preflight answered `401`.
|
||||
|
||||
## The fix is one line, and it is not on the MVC layer
|
||||
|
||||
```java
|
||||
http.cors(Customizer.withDefaults())
|
||||
```
|
||||
|
||||
That is [`MvcBridgeSecurityConfig`](../src/main/java/com/ankurm/cors/config/MvcBridgeSecurityConfig.java),
|
||||
and [`docs/output/02-mvc-bridge.txt`](output/02-mvc-bridge.txt) is the identical application
|
||||
answering `200`. The MVC configuration was fine all along. Nothing was reading it.
|
||||
|
||||
`CorsFilter` short-circuits every preflight it sees:
|
||||
|
||||
```java
|
||||
boolean isValid = this.processor.processRequest(corsConfiguration, request, response);
|
||||
if (!isValid || CorsUtils.isPreFlightRequest(request)) {
|
||||
return; // the chain stops here
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
```
|
||||
|
||||
Read that `if` carefully, because chapter 2 turns on it: the filter returns on **every**
|
||||
preflight, whether or not it found a configuration to apply.
|
||||
|
||||
## One thing that changes when you move the configuration
|
||||
|
||||
Moving CORS from `addCorsMappings` to a `CorsConfigurationSource` bean is not a pure
|
||||
relocation. Compare the two transcripts:
|
||||
|
||||
```
|
||||
02-mvc-bridge.txt Access-Control-Max-Age: 1800
|
||||
03-security-source.txt (nothing)
|
||||
```
|
||||
|
||||
`CorsRegistration` — the builder behind `addCorsMappings` — defaults `maxAge` to
|
||||
1800 seconds. A bare `CorsConfiguration` leaves it `null`, and a preflight response with no
|
||||
`Access-Control-Max-Age` is not cached, so the browser preflights **every single cross-origin
|
||||
call**. Two round trips instead of one, forever, with nothing in any log to suggest it.
|
||||
|
||||
```java
|
||||
configuration.setMaxAge(1800L);
|
||||
```
|
||||
|
||||
---
|
||||
*Next: [2. Who resolves the CorsConfigurationSource](02-who-resolves-the-source.md)*
|
||||
99
cors-csrf/docs/02-who-resolves-the-source.md
Normal file
99
cors-csrf/docs/02-who-resolves-the-source.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# 2. Who resolves the `CorsConfigurationSource`
|
||||
|
||||
*Prev: [1. Two layers, one word](01-two-layers.md) · Next: [3. The three identical 403s](03-three-identical-403s.md)*
|
||||
|
||||
There are two lookups involved in getting a CORS configuration into the filter chain, and they
|
||||
disagree about what they are looking for. One searches by **type**. The other searches by
|
||||
**name**.
|
||||
|
||||
## Lookup 1: should the CORS configurer run at all?
|
||||
|
||||
`HttpSecurityConfiguration.applyCorsIfAvailable(HttpSecurity)`, disassembled from
|
||||
`spring-security-config` 7.1.1:
|
||||
|
||||
```
|
||||
4: ldc // class org/springframework/web/cors/UrlBasedCorsConfigurationSource
|
||||
6: invokeinterface // ApplicationContext.getBeanNamesForType:(Ljava/lang/Class;)[Ljava/lang/String;
|
||||
11: arraylength
|
||||
12: ifle 23
|
||||
19: invokevirtual // HttpSecurity.cors:(Customizer)HttpSecurity
|
||||
23: return
|
||||
```
|
||||
|
||||
By type, and the test is `ifle` — "branch if less than or equal to zero". One bean is
|
||||
enough. So is five.
|
||||
|
||||
> The reference documentation says: *"If you have more than one `CorsConfigurationSource` bean,
|
||||
> Spring Security won't automatically configure CORS support for you, because it cannot decide
|
||||
> which one to use."* That is not what 7.1.1 does.
|
||||
> [`docs/output/06-two-sources.txt`](output/06-two-sources.txt) has two such beans, CORS
|
||||
> configured, and one of them serving traffic.
|
||||
|
||||
## Lookup 2: which source does the configurer use?
|
||||
|
||||
`CorsConfigurer.getCorsConfigurationSource(ApplicationContext)`:
|
||||
|
||||
```
|
||||
7: ldc // String corsConfigurationSource
|
||||
9: invokeinterface // ApplicationContext.containsBeanDefinition:(Ljava/lang/String;)Z
|
||||
20: ldc // String corsConfigurationSource
|
||||
24: invokeinterface // ApplicationContext.getBean:(String,Class)Object
|
||||
34: invokestatic // MvcCorsFilter.getMvcCorsConfigurationSource:(ApplicationContext)CorsConfigurationSource
|
||||
```
|
||||
|
||||
By name. The literal string `corsConfigurationSource`. If no bean definition carries that name,
|
||||
it falls through to Spring MVC's registrations. (There is a similar name check first, for a
|
||||
`CorsFilter` bean named `corsFilter`.)
|
||||
|
||||
## The gap between them
|
||||
|
||||
Name a `UrlBasedCorsConfigurationSource` bean anything other than `corsConfigurationSource` and
|
||||
you land between the two lookups: CORS is switched **on** by the type lookup, and the
|
||||
configuration you wrote is **ignored** by the name lookup.
|
||||
|
||||
That is the `misnamed` profile, and it produces the worst diagnostic in this whole subject:
|
||||
|
||||
```
|
||||
HTTP/1.1 200
|
||||
X-Content-Type-Options: nosniff
|
||||
X-XSS-Protection: 0
|
||||
...
|
||||
```
|
||||
|
||||
Two hundred. No `Access-Control-Allow-Origin`. The browser blocks the request and reports a CORS
|
||||
error; your access log shows a successful `OPTIONS`; nothing anywhere is red.
|
||||
|
||||
Why 200 rather than the 401 from chapter 1? Because `CorsFilter` is now in the chain, and its
|
||||
`if (!isValid || isPreFlightRequest(request)) return;` fires on the second clause. The request
|
||||
never reaches `AuthorizationFilter`. The only trace is one DEBUG line:
|
||||
|
||||
```
|
||||
o.s.web.cors.DefaultCorsProcessor : Skip: no CORS configuration has been provided
|
||||
```
|
||||
|
||||
Full transcript: [`docs/output/05-misnamed-bean.txt`](output/05-misnamed-bean.txt).
|
||||
|
||||
## What is in the context that you did not put there
|
||||
|
||||
`/diag/cors-sources` on a stock Boot web application:
|
||||
|
||||
```json
|
||||
{ "corsConfigurationSourceBeans": { "mvcHandlerMappingIntrospector": "HandlerMappingIntrospector" } }
|
||||
```
|
||||
|
||||
`HandlerMappingIntrospector` implements `CorsConfigurationSource`. It is always there, it is not
|
||||
a `UrlBasedCorsConfigurationSource`, and it is the object the MVC fallback returns. That is why
|
||||
the fallback path never throws in a normal application — and why the failure is silent
|
||||
rather than loud.
|
||||
|
||||
## Rules that follow
|
||||
|
||||
- Name the bean `corsConfigurationSource`. Exactly that.
|
||||
- If you want several, pass them per chain with `.cors(c -> c.configurationSource(..))`, which
|
||||
bypasses both lookups.
|
||||
- `NoSuchBeanDefinitionException: Failed to find a bean that implements
|
||||
\`CorsConfigurationSource\`` names three fixes and does not mention the fourth one, which is
|
||||
usually the right one: rename your bean.
|
||||
|
||||
---
|
||||
*Prev: [1. Two layers, one word](01-two-layers.md) · Next: [3. The three identical 403s](03-three-identical-403s.md)*
|
||||
79
cors-csrf/docs/03-three-identical-403s.md
Normal file
79
cors-csrf/docs/03-three-identical-403s.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# 3. The three identical 403s
|
||||
|
||||
*Prev: [2. Who resolves the CorsConfigurationSource](02-who-resolves-the-source.md) · Next: [4. Preflight handlers](04-preflight-handlers.md)*
|
||||
|
||||
`DefaultCorsProcessor` runs three checks on a preflight, in order: origin, method, request
|
||||
headers. All three failures produce the same thing.
|
||||
|
||||
```
|
||||
HTTP/1.1 403
|
||||
Vary: Origin
|
||||
Vary: Access-Control-Request-Method
|
||||
Vary: Access-Control-Request-Headers
|
||||
|
||||
Invalid CORS request
|
||||
```
|
||||
|
||||
Same status, same body, byte for byte, no `Access-Control-*` header to distinguish them. The
|
||||
assertion in `CorsContractTests.threeRejectionsLookIdentical` compares the three bodies for
|
||||
equality, so if a future version starts distinguishing them, that test fails.
|
||||
|
||||
The only place the difference exists is a DEBUG log line, and the three are worth memorising
|
||||
because they are the fastest CORS diagnosis available:
|
||||
|
||||
```
|
||||
o.s.web.cors.DefaultCorsProcessor : Reject: 'https://evil.example.com' origin is not allowed
|
||||
o.s.web.cors.DefaultCorsProcessor : Reject: HTTP 'DELETE' is not allowed
|
||||
o.s.web.cors.DefaultCorsProcessor : Reject: headers '[authorization]' are not allowed
|
||||
```
|
||||
|
||||
Turn them on with:
|
||||
|
||||
```yaml
|
||||
logging.level.org.springframework.web.cors: DEBUG
|
||||
```
|
||||
|
||||
The complete set of messages, read out of the class's constant pool, is five:
|
||||
|
||||
| Message | Meaning |
|
||||
|---|---|
|
||||
| `Skip: no CORS configuration has been provided` | The source returned `null` for this path — chapter 2 |
|
||||
| `Skip: response already contains "Access-Control-Allow-Origin"` | Something upstream already handled it |
|
||||
| `Reject: origin is malformed` | The `Origin` header did not parse |
|
||||
| `Reject: '…' origin is not allowed` | |
|
||||
| `Reject: HTTP '…' is not allowed` | |
|
||||
| `Reject: headers '[…]' are not allowed` | |
|
||||
|
||||
## Status codes, and what each one means
|
||||
|
||||
Collecting the states this module reproduces:
|
||||
|
||||
| What you see | What it means |
|
||||
|---|---|
|
||||
| `401`/`403`, no `Access-Control-*` at all | No `CorsFilter` in the chain. The preflight was judged by authorization — chapter 1 |
|
||||
| `200`, no `Access-Control-*` | `CorsFilter` is present and found no configuration for this path — chapter 2 |
|
||||
| `403`, `Invalid CORS request` | `CorsFilter` is present and rejected origin, method or headers — this chapter |
|
||||
| `200` with `Access-Control-Allow-Origin` | It worked |
|
||||
| `404`, no `Access-Control-*` | The path is outside the pattern you registered. Common with `/api/**` versus a mis-typed URL |
|
||||
|
||||
The browser reports the same "blocked by CORS policy" for the first four rows. Two of them are
|
||||
not CORS problems.
|
||||
|
||||
## What a **simple** request does
|
||||
|
||||
Only preflighted requests get intercepted. A simple `GET` runs the whole chain, so an
|
||||
unauthenticated one returns 401 — **carrying** the CORS header, because `CorsFilter` at
|
||||
1000 already wrote it before `AuthorizationFilter` at 4200 rejected the request:
|
||||
|
||||
```
|
||||
HTTP/1.1 401
|
||||
Access-Control-Allow-Origin: https://spa.example.com
|
||||
Access-Control-Allow-Credentials: true
|
||||
```
|
||||
|
||||
That is the good case: the SPA's `fetch` resolves and the code can read `response.status`. It is
|
||||
also the reason "my POST fails but my GET returns a readable 401" is a coherent bug report and
|
||||
not a contradiction.
|
||||
|
||||
---
|
||||
*Prev: [2. Who resolves the CorsConfigurationSource](02-who-resolves-the-source.md) · Next: [4. Preflight handlers](04-preflight-handlers.md)*
|
||||
73
cors-csrf/docs/04-preflight-handlers.md
Normal file
73
cors-csrf/docs/04-preflight-handlers.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# 4. `PreFlightRequestHandler`, and the wildcard that is not allowed
|
||||
|
||||
*Prev: [3. The three identical 403s](03-three-identical-403s.md) · Next: [5. The /error dispatch](05-the-error-dispatch.md)*
|
||||
|
||||
## `preFlightRequestHandler`
|
||||
|
||||
`CorsConfigurer` in 7.1.1 has a second setter beside `configurationSource`:
|
||||
|
||||
```java
|
||||
public CorsConfigurer<H> preFlightRequestHandler(PreFlightRequestHandler handler);
|
||||
```
|
||||
|
||||
When one is selected, Spring Security registers Spring Framework's `PreFlightRequestFilter`
|
||||
**before** `CorsFilter` in the chain — `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)*
|
||||
79
cors-csrf/docs/05-the-error-dispatch.md
Normal file
79
cors-csrf/docs/05-the-error-dispatch.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# 5. The `/error` dispatch, or why your 403 arrives as a 401
|
||||
|
||||
*Prev: [4. Preflight handlers](04-preflight-handlers.md) · 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)*
|
||||
114
cors-csrf/docs/06-csrf-for-spas.md
Normal file
114
cors-csrf/docs/06-csrf-for-spas.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# 6. CSRF for SPAs, and what `spa()` actually assigns
|
||||
|
||||
*Prev: [5. The /error dispatch](05-the-error-dispatch.md) · Next: [7. SameSite](07-samesite.md)*
|
||||
|
||||
## The recipe that stopped working in 6.0
|
||||
|
||||
```java
|
||||
http.csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
|
||||
```
|
||||
|
||||
Every SPA tutorial written before Spring Security 6 ends with that line. Three separate things go
|
||||
wrong with it now, and [`docs/output/08-csrf-naive.txt`](output/08-csrf-naive.txt) walks through
|
||||
all three in one transcript.
|
||||
|
||||
**1. The bootstrap GET sets no cookie.** Since 6.0 the token is *deferred*: `CsrfFilter` puts a
|
||||
`Supplier<CsrfToken>` in a request attribute and only materialises it if something dereferences
|
||||
it. A `GET` on a JSON endpoint dereferences nothing. So the SPA starts up, sees no `XSRF-TOKEN`
|
||||
cookie, and its first mutating request has nothing to send.
|
||||
|
||||
**2. Sending the raw cookie value fails.** The default handler is
|
||||
`XorCsrfTokenRequestAttributeHandler`, added as a BREACH mitigation. It expects the value in the
|
||||
header to be XOR-masked. `CookieCsrfTokenRepository` writes the **raw** token into the cookie.
|
||||
So the SPA reads a raw value, sends a raw value, and the handler tries to unmask it. 403.
|
||||
|
||||
**3. The cookie is not sent cross-site anyway.** Chapter 7.
|
||||
|
||||
## What `spa()` is
|
||||
|
||||
Spring Security 7.0 added `CsrfConfigurer.spa()`. Its entire bytecode:
|
||||
|
||||
```
|
||||
0: aload_0
|
||||
1: invokestatic // CookieCsrfTokenRepository.withHttpOnlyFalse()
|
||||
4: putfield // Field csrfTokenRepository
|
||||
7: aload_0
|
||||
8: new // class CsrfConfigurer$SpaCsrfTokenRequestHandler
|
||||
12: invokespecial // <init>
|
||||
15: putfield // Field requestHandler
|
||||
18: aload_0
|
||||
19: areturn
|
||||
```
|
||||
|
||||
Two unconditional assignments. `SpaCsrfTokenRequestHandler` holds two delegates:
|
||||
|
||||
```java
|
||||
private final CsrfTokenRequestAttributeHandler plain = new CsrfTokenRequestAttributeHandler();
|
||||
private final CsrfTokenRequestAttributeHandler xor = new XorCsrfTokenRequestAttributeHandler();
|
||||
// constructor: xor.setCsrfRequestAttributeName(null);
|
||||
```
|
||||
|
||||
`handle(..)` always delegates to `xor`. `resolveCsrfTokenValue(..)` picks `plain` when the
|
||||
request carries the header and `xor` otherwise:
|
||||
|
||||
```java
|
||||
String headerValue = request.getHeader(csrfToken.getHeaderName());
|
||||
return (StringUtils.hasText(headerValue) ? this.plain : this.xor)
|
||||
.resolveCsrfTokenValue(request, csrfToken);
|
||||
```
|
||||
|
||||
So a SPA reading the cookie and echoing it in `X-XSRF-TOKEN` compares raw against raw and
|
||||
succeeds, while a `<form>` post keeps the BREACH masking on the hidden field. Both work, from
|
||||
one configuration.
|
||||
|
||||
## The part nobody documents: why the cookie now appears on the GET
|
||||
|
||||
That `xor.setCsrfRequestAttributeName(null)` looks like a detail. It is the fix for problem 1.
|
||||
|
||||
`CsrfTokenRequestAttributeHandler.handle` wraps the supplier in a `SupplierCsrfToken` and sets
|
||||
two request attributes. The key for the second one is the configured attribute name — or,
|
||||
when that is `null`, `csrfToken.getParameterName()`. Calling `getParameterName()` on a
|
||||
`SupplierCsrfToken` **dereferences the supplier**. The token is generated, the repository saves
|
||||
it, and the `Set-Cookie` header goes out.
|
||||
|
||||
The eager rendering is a side effect of needing a string for a map key. It is real, it is
|
||||
load-bearing, and [`docs/output/10-csrf-spa.txt`](output/10-csrf-spa.txt) shows the cookie
|
||||
arriving on the bootstrap `GET` where `08` showed nothing.
|
||||
|
||||
## `spa()` discards what you configured before it
|
||||
|
||||
Because the two assignments are unconditional:
|
||||
|
||||
```java
|
||||
.csrf(csrf -> csrf.csrfTokenRepository(myRepository).spa()) // myRepository is gone
|
||||
.csrf(csrf -> csrf.spa().csrfTokenRepository(myRepository)) // this one wins
|
||||
```
|
||||
|
||||
The `spaorder` profile asks for a cookie named `MY-CSRF` and a header named `X-CSRF-TOKEN`;
|
||||
[`docs/output/11-spa-ordering.txt`](output/11-spa-ordering.txt) shows `XSRF-TOKEN` coming back
|
||||
instead. Tracked as [spring-security#18718](https://github.com/spring-projects/spring-security/issues/18718).
|
||||
|
||||
## A CSRF rejection does not look like a CSRF rejection
|
||||
|
||||
`CsrfFilter` logs, at DEBUG:
|
||||
|
||||
```
|
||||
o.s.security.web.csrf.CsrfFilter : Invalid CSRF token found for http://localhost:8080/api/data
|
||||
```
|
||||
|
||||
and hands off to its `AccessDeniedHandler`, which calls `sendError(403)`. What the client
|
||||
receives, in a Basic-authenticated API with no `/error` chain, is **401 with
|
||||
`WWW-Authenticate: Basic`** — chapter 5. Every hour spent checking credentials after a
|
||||
403-that-is-a-401 is spent on the wrong thing.
|
||||
|
||||
Turn on `logging.level.org.springframework.security.web.csrf: DEBUG` before anything else.
|
||||
|
||||
## Do you need CSRF at all?
|
||||
|
||||
If the API authenticates with a `Bearer` token held in memory and never with a cookie, then no:
|
||||
there is no ambient credential for a third-party page to ride on, and `csrf.disable()` is
|
||||
correct rather than lazy. If any part of the session lives in a cookie — including a
|
||||
`HttpOnly` refresh cookie — then yes, and `spa()` is the shortest correct configuration.
|
||||
|
||||
---
|
||||
*Prev: [5. The /error dispatch](05-the-error-dispatch.md) · Next: [7. SameSite](07-samesite.md)*
|
||||
111
cors-csrf/docs/07-samesite.md
Normal file
111
cors-csrf/docs/07-samesite.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# 7. SameSite, `Secure`, and the cookie that is never stored
|
||||
|
||||
*Prev: [6. CSRF for SPAs](06-csrf-for-spas.md) · Next: [8. Debugging recipes](08-debugging-recipes.md)*
|
||||
|
||||
CORS decides whether the browser lets your JavaScript *read* a response. SameSite decides whether
|
||||
the browser *sends the cookie* in the first place. Getting CORS perfect and SameSite wrong
|
||||
produces a request that arrives cleanly and is anonymous.
|
||||
|
||||
## What Spring emits by default
|
||||
|
||||
From [`docs/output/12-samesite.txt`](output/12-samesite.txt), under `csrf.spa()`:
|
||||
|
||||
```
|
||||
Set-Cookie: XSRF-TOKEN=<token>; Path=/
|
||||
Set-Cookie: JSESSIONID=<session>; Path=/; HttpOnly; SameSite=Lax
|
||||
```
|
||||
|
||||
The session cookie gets `SameSite=Lax` from Boot's
|
||||
`server.servlet.session.cookie.same-site` default. The CSRF cookie gets **no SameSite attribute
|
||||
at all**: `CookieCsrfTokenRepository`'s default cookie customizer is, in bytecode, a single
|
||||
`return`. Nothing is set.
|
||||
|
||||
An absent `SameSite` is not "no restriction". Chromium-based browsers treat it as `Lax`; Firefox has
|
||||
**not** enabled Lax-by-default on its release channel (`network.cookie.sameSite.laxByDefault` is on in
|
||||
Nightly only). The two disagree, which is why "it works in Firefox and not in Chrome" is so often a
|
||||
missing `SameSite` attribute. `SpecCookieJar` models the Chromium behaviour, because that is the one
|
||||
you have to survive.
|
||||
|
||||
## The two rules that matter
|
||||
|
||||
**Storage (RFC 6265bis §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)*
|
||||
77
cors-csrf/docs/08-debugging-recipes.md
Normal file
77
cors-csrf/docs/08-debugging-recipes.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# 8. Debugging recipes
|
||||
|
||||
*Prev: [7. SameSite](07-samesite.md)*
|
||||
|
||||
## Turn on the two log categories first
|
||||
|
||||
```yaml
|
||||
logging:
|
||||
level:
|
||||
org.springframework.web.cors: DEBUG # DefaultCorsProcessor's Skip:/Reject: lines
|
||||
org.springframework.security.web.csrf: DEBUG # "Invalid CSRF token found for ..."
|
||||
```
|
||||
|
||||
Almost every question in this subject is answered by one line from one of those two.
|
||||
|
||||
## Reproduce the preflight without a browser
|
||||
|
||||
```bash
|
||||
curl -s -i -X OPTIONS http://localhost:8080/api/data \
|
||||
-H 'Origin: https://spa.example.com' \
|
||||
-H 'Access-Control-Request-Method: POST' \
|
||||
-H 'Access-Control-Request-Headers: content-type,x-xsrf-token'
|
||||
```
|
||||
|
||||
That is the whole preflight. `scripts/preflight.sh` wraps it. Note the absence of `-u` and
|
||||
`-b`: the browser sends no credentials on a preflight, and reproducing it *with* credentials
|
||||
hides the bug.
|
||||
|
||||
## Is `CorsFilter` even in the chain?
|
||||
|
||||
```bash
|
||||
curl -s localhost:8080/diag/chain | python3 -m json.tool
|
||||
```
|
||||
|
||||
If `CorsFilter` is absent, no amount of MVC configuration will help — 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)*
|
||||
58
cors-csrf/docs/output/01-mvc-only.txt
Normal file
58
cors-csrf/docs/output/01-mvc-only.txt
Normal file
@@ -0,0 +1,58 @@
|
||||
==============================================================================
|
||||
docs/output/01-mvc-only.txt
|
||||
CORS configured with WebMvcConfigurer.addCorsMappings and nothing else.
|
||||
Profile: mvconly
|
||||
==============================================================================
|
||||
|
||||
# The security chain. Note what is NOT in it.
|
||||
$ curl -s localhost:8080/diag/chain
|
||||
{
|
||||
"profiles": [
|
||||
"mvconly"
|
||||
],
|
||||
"chains": [
|
||||
{
|
||||
"size": 11,
|
||||
"filters": [
|
||||
"DisableEncodeUrlFilter",
|
||||
"WebAsyncManagerIntegrationFilter",
|
||||
"SecurityContextHolderFilter",
|
||||
"HeaderWriterFilter",
|
||||
"LogoutFilter",
|
||||
"BasicAuthenticationFilter",
|
||||
"RequestCacheAwareFilter",
|
||||
"SecurityContextHolderAwareRequestFilter",
|
||||
"AnonymousAuthenticationFilter",
|
||||
"ExceptionTranslationFilter",
|
||||
"AuthorizationFilter"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# CorsConfigurationSource beans in the context.
|
||||
$ curl -s localhost:8080/diag/cors-sources
|
||||
{
|
||||
"corsConfigurationSourceBeans": {
|
||||
"mvcHandlerMappingIntrospector": "HandlerMappingIntrospector"
|
||||
},
|
||||
"hasBeanNamedCorsConfigurationSource": false
|
||||
}
|
||||
|
||||
$ curl -s -i -X OPTIONS http://localhost:8080/api/data \
|
||||
-H 'Origin: https://spa.example.com' \
|
||||
-H 'Access-Control-Request-Method: POST' \
|
||||
-H 'Access-Control-Request-Headers: content-type,x-xsrf-token'
|
||||
|
||||
HTTP/1.1 401
|
||||
Set-Cookie: JSESSIONID=<session>; Path=/; HttpOnly; SameSite=Lax
|
||||
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
|
||||
X-Content-Type-Options: nosniff
|
||||
X-XSS-Protection: 0
|
||||
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
|
||||
Pragma: no-cache
|
||||
X-Frame-Options: DENY
|
||||
|
||||
# The MVC CORS mapping is real - it just never runs, because the request is
|
||||
# rejected at AuthorizationFilter (order 4200) and the DispatcherServlet is
|
||||
# downstream of the entire filter chain.
|
||||
56
cors-csrf/docs/output/02-mvc-bridge.txt
Normal file
56
cors-csrf/docs/output/02-mvc-bridge.txt
Normal file
@@ -0,0 +1,56 @@
|
||||
==============================================================================
|
||||
docs/output/02-mvc-bridge.txt
|
||||
The identical MVC CORS mapping plus one line: .cors(Customizer.withDefaults()).
|
||||
Profile: mvcbridge
|
||||
==============================================================================
|
||||
|
||||
$ curl -s localhost:8080/diag/chain
|
||||
{
|
||||
"profiles": [
|
||||
"mvcbridge"
|
||||
],
|
||||
"chains": [
|
||||
{
|
||||
"size": 12,
|
||||
"filters": [
|
||||
"DisableEncodeUrlFilter",
|
||||
"WebAsyncManagerIntegrationFilter",
|
||||
"SecurityContextHolderFilter",
|
||||
"HeaderWriterFilter",
|
||||
"CorsFilter",
|
||||
"LogoutFilter",
|
||||
"BasicAuthenticationFilter",
|
||||
"RequestCacheAwareFilter",
|
||||
"SecurityContextHolderAwareRequestFilter",
|
||||
"AnonymousAuthenticationFilter",
|
||||
"ExceptionTranslationFilter",
|
||||
"AuthorizationFilter"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
$ curl -s -i -X OPTIONS http://localhost:8080/api/data \
|
||||
-H 'Origin: https://spa.example.com' \
|
||||
-H 'Access-Control-Request-Method: POST' \
|
||||
-H 'Access-Control-Request-Headers: content-type,x-xsrf-token'
|
||||
|
||||
HTTP/1.1 200
|
||||
Vary: Origin
|
||||
Vary: Access-Control-Request-Method
|
||||
Vary: Access-Control-Request-Headers
|
||||
Access-Control-Allow-Origin: https://spa.example.com
|
||||
Access-Control-Allow-Methods: GET,POST
|
||||
Access-Control-Allow-Headers: content-type, x-xsrf-token
|
||||
Access-Control-Allow-Credentials: true
|
||||
Access-Control-Max-Age: 1800
|
||||
X-Content-Type-Options: nosniff
|
||||
X-XSS-Protection: 0
|
||||
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
|
||||
Pragma: no-cache
|
||||
X-Frame-Options: DENY
|
||||
|
||||
# CorsFilter is now in the chain at order 1000, between HeaderWriterFilter (900)
|
||||
# and CsrfFilter (1100), and it short-circuits the preflight before authorization
|
||||
# ever sees it. Note Access-Control-Max-Age: 1800 - that default comes from MVC's
|
||||
# CorsRegistration, not from CorsConfiguration.
|
||||
36
cors-csrf/docs/output/03-security-source.txt
Normal file
36
cors-csrf/docs/output/03-security-source.txt
Normal file
@@ -0,0 +1,36 @@
|
||||
==============================================================================
|
||||
docs/output/03-security-source.txt
|
||||
A @Bean named corsConfigurationSource. .cors(..) is never called - it is applied for us.
|
||||
Profile: securitysource
|
||||
==============================================================================
|
||||
|
||||
$ curl -s localhost:8080/diag/cors-sources
|
||||
{
|
||||
"corsConfigurationSourceBeans": {
|
||||
"corsConfigurationSource": "UrlBasedCorsConfigurationSource",
|
||||
"mvcHandlerMappingIntrospector": "HandlerMappingIntrospector"
|
||||
},
|
||||
"hasBeanNamedCorsConfigurationSource": true
|
||||
}
|
||||
|
||||
$ curl -s -i -X OPTIONS http://localhost:8080/api/data \
|
||||
-H 'Origin: https://spa.example.com' \
|
||||
-H 'Access-Control-Request-Method: POST' \
|
||||
-H 'Access-Control-Request-Headers: content-type,x-xsrf-token'
|
||||
|
||||
HTTP/1.1 200
|
||||
Vary: Origin
|
||||
Vary: Access-Control-Request-Method
|
||||
Vary: Access-Control-Request-Headers
|
||||
Access-Control-Allow-Origin: https://spa.example.com
|
||||
Access-Control-Allow-Methods: GET,POST
|
||||
Access-Control-Allow-Headers: content-type, x-xsrf-token
|
||||
Access-Control-Allow-Credentials: true
|
||||
X-Content-Type-Options: nosniff
|
||||
X-XSS-Protection: 0
|
||||
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
|
||||
Pragma: no-cache
|
||||
X-Frame-Options: DENY
|
||||
|
||||
# Compare with 02: there is no Access-Control-Max-Age here. CorsConfiguration
|
||||
# leaves maxAge null, so every single cross-origin call re-runs the preflight.
|
||||
47
cors-csrf/docs/output/04-three-identical-403s.txt
Normal file
47
cors-csrf/docs/output/04-three-identical-403s.txt
Normal file
@@ -0,0 +1,47 @@
|
||||
==============================================================================
|
||||
docs/output/04-three-identical-403s.txt
|
||||
Origin not allowed, method not allowed, header not allowed. One status, one shape.
|
||||
Profile: securitysource, CORS_LOG_LEVEL=DEBUG
|
||||
==============================================================================
|
||||
|
||||
# 1. disallowed origin
|
||||
HTTP/1.1 403
|
||||
Vary: Origin
|
||||
Vary: Access-Control-Request-Method
|
||||
Vary: Access-Control-Request-Headers
|
||||
X-Content-Type-Options: nosniff
|
||||
X-XSS-Protection: 0
|
||||
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
|
||||
Pragma: no-cache
|
||||
X-Frame-Options: DENY
|
||||
|
||||
# 2. disallowed method
|
||||
HTTP/1.1 403
|
||||
Vary: Origin
|
||||
Vary: Access-Control-Request-Method
|
||||
Vary: Access-Control-Request-Headers
|
||||
X-Content-Type-Options: nosniff
|
||||
X-XSS-Protection: 0
|
||||
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
|
||||
Pragma: no-cache
|
||||
X-Frame-Options: DENY
|
||||
|
||||
# 3. disallowed request header
|
||||
HTTP/1.1 403
|
||||
Vary: Origin
|
||||
Vary: Access-Control-Request-Method
|
||||
Vary: Access-Control-Request-Headers
|
||||
X-Content-Type-Options: nosniff
|
||||
X-XSS-Protection: 0
|
||||
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
|
||||
Pragma: no-cache
|
||||
X-Frame-Options: DENY
|
||||
|
||||
# body of a rejected preflight:
|
||||
Invalid CORS request
|
||||
|
||||
# The only thing that distinguishes them is a DEBUG line from DefaultCorsProcessor:
|
||||
<timestamp> DEBUG <pid> --- [cors-csrf-samesite] [nio-8080-exec-N] o.s.web.cors.DefaultCorsProcessor : Reject: 'https://evil.example.com' origin is not allowed
|
||||
<timestamp> DEBUG <pid> --- [cors-csrf-samesite] [nio-8080-exec-N] o.s.web.cors.DefaultCorsProcessor : Reject: HTTP 'DELETE' is not allowed
|
||||
<timestamp> DEBUG <pid> --- [cors-csrf-samesite] [nio-8080-exec-N] o.s.web.cors.DefaultCorsProcessor : Reject: headers '[authorization]' are not allowed
|
||||
<timestamp> DEBUG <pid> --- [cors-csrf-samesite] [nio-8080-exec-N] o.s.web.cors.DefaultCorsProcessor : Reject: 'https://evil.example.com' origin is not allowed
|
||||
39
cors-csrf/docs/output/05-misnamed-bean.txt
Normal file
39
cors-csrf/docs/output/05-misnamed-bean.txt
Normal file
@@ -0,0 +1,39 @@
|
||||
==============================================================================
|
||||
docs/output/05-misnamed-bean.txt
|
||||
The same UrlBasedCorsConfigurationSource bean, named apiCorsSource instead of
|
||||
corsConfigurationSource. It starts. The preflight returns 200. It carries no CORS headers.
|
||||
Profile: misnamed
|
||||
==============================================================================
|
||||
|
||||
$ curl -s localhost:8080/diag/cors-sources
|
||||
{
|
||||
"corsConfigurationSourceBeans": {
|
||||
"apiCorsSource": "UrlBasedCorsConfigurationSource",
|
||||
"mvcHandlerMappingIntrospector": "HandlerMappingIntrospector"
|
||||
},
|
||||
"hasBeanNamedCorsConfigurationSource": false
|
||||
}
|
||||
|
||||
$ curl -s -i -X OPTIONS http://localhost:8080/api/data \
|
||||
-H 'Origin: https://spa.example.com' \
|
||||
-H 'Access-Control-Request-Method: POST' \
|
||||
-H 'Access-Control-Request-Headers: content-type,x-xsrf-token'
|
||||
|
||||
HTTP/1.1 200
|
||||
X-Content-Type-Options: nosniff
|
||||
X-XSS-Protection: 0
|
||||
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
|
||||
Pragma: no-cache
|
||||
X-Frame-Options: DENY
|
||||
|
||||
<timestamp> DEBUG <pid> --- [cors-csrf-samesite] [nio-8080-exec-N] o.s.web.cors.DefaultCorsProcessor : Skip: no CORS configuration has been provided
|
||||
|
||||
# Two different lookups. HttpSecurityConfiguration.applyCorsIfAvailable asks
|
||||
# getBeanNamesForType(UrlBasedCorsConfigurationSource.class) and enables CORS if the
|
||||
# array is non-empty, so the bean above DID switch the configurer on.
|
||||
# CorsConfigurer.getCorsConfigurationSource then asks
|
||||
# containsBeanDefinition("corsConfigurationSource"), which is false, and falls back
|
||||
# to Spring MVC's registrations - of which there are none.
|
||||
# CorsFilter returns from every preflight whether or not it found a configuration:
|
||||
# if (!isValid || CorsUtils.isPreFlightRequest(request)) { return; }
|
||||
# so the OPTIONS never reaches AuthorizationFilter and the client gets a bare 200.
|
||||
44
cors-csrf/docs/output/06-two-sources.txt
Normal file
44
cors-csrf/docs/output/06-two-sources.txt
Normal file
@@ -0,0 +1,44 @@
|
||||
==============================================================================
|
||||
docs/output/06-two-sources.txt
|
||||
Two UrlBasedCorsConfigurationSource beans. The reference documentation says Spring Security
|
||||
'won't automatically configure CORS support for you, because it cannot decide which one to
|
||||
use'. In 7.1.1 it configures it, and the bean NAME decides.
|
||||
Profile: twosources
|
||||
==============================================================================
|
||||
|
||||
{
|
||||
"corsConfigurationSourceBeans": {
|
||||
"corsConfigurationSource": "UrlBasedCorsConfigurationSource",
|
||||
"adminCorsSource": "UrlBasedCorsConfigurationSource",
|
||||
"mvcHandlerMappingIntrospector": "HandlerMappingIntrospector"
|
||||
},
|
||||
"hasBeanNamedCorsConfigurationSource": true
|
||||
}
|
||||
|
||||
# the origin allowed by the bean named corsConfigurationSource:
|
||||
HTTP/1.1 200
|
||||
Vary: Origin
|
||||
Vary: Access-Control-Request-Method
|
||||
Vary: Access-Control-Request-Headers
|
||||
Access-Control-Allow-Origin: https://spa.example.com
|
||||
Access-Control-Allow-Methods: GET,POST
|
||||
Access-Control-Allow-Headers: content-type
|
||||
Access-Control-Allow-Credentials: true
|
||||
X-Content-Type-Options: nosniff
|
||||
X-XSS-Protection: 0
|
||||
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
|
||||
Pragma: no-cache
|
||||
X-Frame-Options: DENY
|
||||
|
||||
# the origin allowed by adminCorsSource, which is never consulted:
|
||||
HTTP/1.1 403
|
||||
Vary: Origin
|
||||
Vary: Access-Control-Request-Method
|
||||
Vary: Access-Control-Request-Headers
|
||||
X-Content-Type-Options: nosniff
|
||||
X-XSS-Protection: 0
|
||||
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
|
||||
Pragma: no-cache
|
||||
X-Frame-Options: DENY
|
||||
|
||||
<timestamp> DEBUG <pid> --- [cors-csrf-samesite] [nio-8080-exec-N] o.s.web.cors.DefaultCorsProcessor : Reject: 'https://admin.example.com' origin is not allowed
|
||||
41
cors-csrf/docs/output/07-wildcard-credentials.txt
Normal file
41
cors-csrf/docs/output/07-wildcard-credentials.txt
Normal file
@@ -0,0 +1,41 @@
|
||||
==============================================================================
|
||||
docs/output/07-wildcard-credentials.txt
|
||||
allowedOrigins("*") together with allowCredentials(true). Legal to configure, illegal to
|
||||
serve. The failure is thrown on the request, not at startup - and it does not surface as a 500.
|
||||
Profile: wildcard
|
||||
==============================================================================
|
||||
|
||||
HTTP/1.1 401
|
||||
Vary: Origin
|
||||
Vary: Access-Control-Request-Method
|
||||
Vary: Access-Control-Request-Headers
|
||||
X-Content-Type-Options: nosniff
|
||||
X-XSS-Protection: 0
|
||||
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
|
||||
Pragma: no-cache
|
||||
X-Frame-Options: DENY
|
||||
Set-Cookie: JSESSIONID=<session>; Path=/; HttpOnly; SameSite=Lax
|
||||
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
|
||||
|
||||
# and a plain authenticated GET, with correct credentials:
|
||||
HTTP/1.1 401
|
||||
Vary: Origin
|
||||
Vary: Access-Control-Request-Method
|
||||
Vary: Access-Control-Request-Headers
|
||||
X-Content-Type-Options: nosniff
|
||||
X-XSS-Protection: 0
|
||||
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
|
||||
Pragma: no-cache
|
||||
X-Frame-Options: DENY
|
||||
Set-Cookie: JSESSIONID=<session>; Path=/; HttpOnly; SameSite=Lax
|
||||
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
|
||||
|
||||
java.lang.IllegalArgumentException: When allowCredentials is true, allowedOrigins cannot contain the special value "*" since that cannot be set on the "Access-Control-Allow-Origin" response header. To allow credentials to a set of origins, list them explicitly or consider using "allowedOriginPatterns" instead.
|
||||
at org.springframework.web.cors.CorsConfiguration.validateAllowCredentials(CorsConfiguration.java:552) ~[spring-web-7.0.9.jar:7.0.9]
|
||||
at org.springframework.web.cors.CorsConfiguration.checkOrigin(CorsConfiguration.java:678) ~[spring-web-7.0.9.jar:7.0.9]
|
||||
at org.springframework.web.cors.DefaultCorsProcessor.checkOrigin(DefaultCorsProcessor.java:193) ~[spring-web-7.0.9.jar:7.0.9]
|
||||
at org.springframework.web.cors.DefaultCorsProcessor.handleInternal(DefaultCorsProcessor.java:131) ~[spring-web-7.0.9.jar:7.0.9]
|
||||
|
||||
# 401, not 500. The exception escapes CorsFilter, Tomcat re-dispatches to /error,
|
||||
# the security chain runs again on that dispatch without re-reading the credential,
|
||||
# and the anonymous second pass is what answers.
|
||||
54
cors-csrf/docs/output/08-csrf-naive.txt
Normal file
54
cors-csrf/docs/output/08-csrf-naive.txt
Normal file
@@ -0,0 +1,54 @@
|
||||
==============================================================================
|
||||
docs/output/08-csrf-naive.txt
|
||||
CookieCsrfTokenRepository.withHttpOnlyFalse() on its own - the recipe from every pre-6.0
|
||||
tutorial. Three separate things go wrong.
|
||||
Profile: csrfnaive
|
||||
==============================================================================
|
||||
|
||||
# 1. The bootstrap GET. A SPA expects an XSRF-TOKEN cookie here.
|
||||
HTTP/1.1 200
|
||||
Vary: Origin
|
||||
Vary: Access-Control-Request-Method
|
||||
Vary: Access-Control-Request-Headers
|
||||
X-Content-Type-Options: nosniff
|
||||
X-XSS-Protection: 0
|
||||
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
|
||||
Pragma: no-cache
|
||||
X-Frame-Options: DENY
|
||||
|
||||
# cookie jar after the GET:
|
||||
(empty - no cookie was set)
|
||||
|
||||
# 2. POST with no token.
|
||||
HTTP/1.1 401
|
||||
Vary: Origin
|
||||
Vary: Access-Control-Request-Method
|
||||
Vary: Access-Control-Request-Headers
|
||||
Set-Cookie: XSRF-TOKEN=<token>; Path=/
|
||||
X-Content-Type-Options: nosniff
|
||||
X-XSS-Protection: 0
|
||||
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
|
||||
Pragma: no-cache
|
||||
X-Frame-Options: DENY
|
||||
Set-Cookie: JSESSIONID=<session>; Path=/; HttpOnly; SameSite=Lax
|
||||
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
|
||||
|
||||
# cookie jar now:
|
||||
|
||||
localhost | FALSE | / | FALSE | 0 | XSRF-TOKEN | 4888debb-2e51-4742-b0e7-262c489825b9
|
||||
|
||||
# 3. POST echoing the raw cookie value back in X-XSRF-TOKEN, which is what every
|
||||
# SPA snippet on the internet does.
|
||||
HTTP/1.1 401
|
||||
Vary: Origin
|
||||
Vary: Access-Control-Request-Method
|
||||
Vary: Access-Control-Request-Headers
|
||||
X-Content-Type-Options: nosniff
|
||||
X-XSS-Protection: 0
|
||||
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
|
||||
Pragma: no-cache
|
||||
X-Frame-Options: DENY
|
||||
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
|
||||
|
||||
<timestamp> DEBUG <pid> --- [cors-csrf-samesite] [nio-8080-exec-N] o.s.security.web.csrf.CsrfFilter : Invalid CSRF token found for http://localhost:8080/api/data
|
||||
<timestamp> DEBUG <pid> --- [cors-csrf-samesite] [nio-8080-exec-N] o.s.security.web.csrf.CsrfFilter : Invalid CSRF token found for http://localhost:8080/api/data
|
||||
25
cors-csrf/docs/output/09-error-dispatch.txt
Normal file
25
cors-csrf/docs/output/09-error-dispatch.txt
Normal file
@@ -0,0 +1,25 @@
|
||||
==============================================================================
|
||||
docs/output/09-error-dispatch.txt
|
||||
The identical CSRF failure, with one extra filter chain that permits /error.
|
||||
Profile: csrfnaive,errorpermit
|
||||
==============================================================================
|
||||
|
||||
HTTP/1.1 403
|
||||
Vary: Origin
|
||||
Vary: Access-Control-Request-Method
|
||||
Vary: Access-Control-Request-Headers
|
||||
Set-Cookie: XSRF-TOKEN=<token>; Path=/
|
||||
X-Content-Type-Options: nosniff
|
||||
X-XSS-Protection: 0
|
||||
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
|
||||
Pragma: no-cache
|
||||
X-Frame-Options: DENY
|
||||
|
||||
{"timestamp":"<timestamp>","status":403,"error":"Forbidden","path":"/api/data"}
|
||||
|
||||
# 403, and a body. Without the /error chain the same request answers 401 with an
|
||||
# empty body and a WWW-Authenticate header - see 08. AccessDeniedHandlerImpl calls
|
||||
# response.sendError(403), the container re-dispatches to /error, and the security
|
||||
# chain runs a second time on that dispatch. BasicAuthenticationFilter extends
|
||||
# OncePerRequestFilter and skips error dispatches, so the second pass is anonymous
|
||||
# and AuthorizationFilter answers 401 over the top of the 403.
|
||||
47
cors-csrf/docs/output/10-csrf-spa.txt
Normal file
47
cors-csrf/docs/output/10-csrf-spa.txt
Normal file
@@ -0,0 +1,47 @@
|
||||
==============================================================================
|
||||
docs/output/10-csrf-spa.txt
|
||||
The same flow under csrf.spa(), added in Spring Security 7.0.
|
||||
Profile: csrfspa
|
||||
==============================================================================
|
||||
|
||||
# 1. The bootstrap GET now DOES set the cookie.
|
||||
HTTP/1.1 200
|
||||
Vary: Origin
|
||||
Vary: Access-Control-Request-Method
|
||||
Vary: Access-Control-Request-Headers
|
||||
Set-Cookie: XSRF-TOKEN=<token>; Path=/
|
||||
X-Content-Type-Options: nosniff
|
||||
X-XSS-Protection: 0
|
||||
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
|
||||
Pragma: no-cache
|
||||
X-Frame-Options: DENY
|
||||
|
||||
# 2. POST with no token still fails, as it must.
|
||||
HTTP/1.1 401
|
||||
Vary: Origin
|
||||
Vary: Access-Control-Request-Method
|
||||
Vary: Access-Control-Request-Headers
|
||||
X-Content-Type-Options: nosniff
|
||||
X-XSS-Protection: 0
|
||||
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
|
||||
Pragma: no-cache
|
||||
X-Frame-Options: DENY
|
||||
Set-Cookie: JSESSIONID=<session>; Path=/; HttpOnly; SameSite=Lax
|
||||
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
|
||||
|
||||
# 3. POST echoing the raw cookie value in X-XSRF-TOKEN.
|
||||
HTTP/1.1 200
|
||||
Vary: Origin
|
||||
Vary: Access-Control-Request-Method
|
||||
Vary: Access-Control-Request-Headers
|
||||
X-Content-Type-Options: nosniff
|
||||
X-XSS-Protection: 0
|
||||
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
|
||||
Pragma: no-cache
|
||||
X-Frame-Options: DENY
|
||||
|
||||
{"created":true,"received":"{}","cookies":"XSRF-TOKEN"}
|
||||
|
||||
# Note the cookie attributes: Path=/ and nothing else. No SameSite, no Secure,
|
||||
# no HttpOnly. A cookie with no SameSite attribute is treated as Lax, so a
|
||||
# genuinely cross-site SPA still never receives it. See 12.
|
||||
24
cors-csrf/docs/output/11-spa-ordering.txt
Normal file
24
cors-csrf/docs/output/11-spa-ordering.txt
Normal file
@@ -0,0 +1,24 @@
|
||||
==============================================================================
|
||||
docs/output/11-spa-ordering.txt
|
||||
.csrf(c -> c.csrfTokenRepository(custom).spa()) - the custom repository asked for a cookie
|
||||
named MY-CSRF and a header named X-CSRF-TOKEN. Neither reaches the running application.
|
||||
Profile: spaorder
|
||||
==============================================================================
|
||||
|
||||
HTTP/1.1 200
|
||||
Vary: Origin
|
||||
Vary: Access-Control-Request-Method
|
||||
Vary: Access-Control-Request-Headers
|
||||
Set-Cookie: XSRF-TOKEN=<token>; Path=/
|
||||
X-Content-Type-Options: nosniff
|
||||
X-XSS-Protection: 0
|
||||
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
|
||||
Pragma: no-cache
|
||||
X-Frame-Options: DENY
|
||||
|
||||
# cookie jar:
|
||||
|
||||
localhost | FALSE | / | FALSE | 0 | XSRF-TOKEN | 42573eea-76d6-4bc6-a14b-bff76640461d
|
||||
|
||||
# spa() assigns csrfTokenRepository and requestHandler unconditionally; it is not a
|
||||
# 'defaults if unset' method. Swap the two calls and MY-CSRF appears.
|
||||
59
cors-csrf/docs/output/12-samesite.txt
Normal file
59
cors-csrf/docs/output/12-samesite.txt
Normal file
@@ -0,0 +1,59 @@
|
||||
==============================================================================
|
||||
docs/output/12-samesite.txt
|
||||
The Set-Cookie headers this application emits under four configurations, and what
|
||||
SpecCookieJar - a model of RFC 6265bis 5.5 and 5.8.3 - does with them.
|
||||
==============================================================================
|
||||
|
||||
## csrf.spa() defaults, session cookie left at same-site=lax
|
||||
Set-Cookie: XSRF-TOKEN=<token>; Path=/
|
||||
Set-Cookie: JSESSIONID=<session>; Path=/; HttpOnly; SameSite=Lax
|
||||
|
||||
## session cookie set to same-site=none, secure=false
|
||||
Set-Cookie: XSRF-TOKEN=<token>; Path=/
|
||||
Set-Cookie: JSESSIONID=<session>; Path=/; HttpOnly; SameSite=None
|
||||
|
||||
## crosssite profile: SameSite=None and Secure on both cookies
|
||||
Set-Cookie: XSRF-TOKEN=<token>; Path=/; Secure; SameSite=None
|
||||
Set-Cookie: JSESSIONID=<session>; Path=/; Secure; HttpOnly; SameSite=None
|
||||
|
||||
## crosssite profile with -DOMIT_SECURE=true
|
||||
Set-Cookie: XSRF-TOKEN=<token>; Path=/; SameSite=None
|
||||
Set-Cookie: JSESSIONID=<session>; Path=/; HttpOnly; SameSite=None
|
||||
|
||||
## The same headers, run through SpecCookieJar
|
||||
{
|
||||
"origin": "not trustworthy (plain http)",
|
||||
"setCookieOutcomes": {
|
||||
"JSESSIONID=s1; Path=/; HttpOnly; SameSite=Lax": "stored",
|
||||
"JSESSIONID=s2; Path=/; HttpOnly; SameSite=None": "REJECTED JSESSIONID: SameSite=None and no Secure attribute - RFC 6265bis 5.5",
|
||||
"JSESSIONID=s3; Path=/; Secure; HttpOnly; SameSite=None": "REJECTED JSESSIONID: SameSite=None with Secure, but the origin is not trustworthy so Secure is not honoured - RFC 6265bis 5.5",
|
||||
"XSRF-TOKEN=t1; Path=/": "stored",
|
||||
"XSRF-TOKEN=t2; Path=/; SameSite=None": "REJECTED XSRF-TOKEN: SameSite=None and no Secure attribute - RFC 6265bis 5.5",
|
||||
"XSRF-TOKEN=t3; Path=/; Secure; SameSite=None": "REJECTED XSRF-TOKEN: SameSite=None with Secure, but the origin is not trustworthy so Secure is not honoured - RFC 6265bis 5.5"
|
||||
},
|
||||
"sentOnSameSiteRequest": "JSESSIONID=s1; XSRF-TOKEN=t1",
|
||||
"sentOnCrossSiteTopLevelNavigation": "JSESSIONID=s1; XSRF-TOKEN=t1",
|
||||
"sentOnCrossSiteFetch": "(no cookies sent)"
|
||||
}
|
||||
|
||||
{
|
||||
"origin": "trustworthy (https, or http://localhost)",
|
||||
"setCookieOutcomes": {
|
||||
"JSESSIONID=s1; Path=/; HttpOnly; SameSite=Lax": "stored",
|
||||
"JSESSIONID=s2; Path=/; HttpOnly; SameSite=None": "REJECTED JSESSIONID: SameSite=None and no Secure attribute - RFC 6265bis 5.5",
|
||||
"JSESSIONID=s3; Path=/; Secure; HttpOnly; SameSite=None": "stored",
|
||||
"XSRF-TOKEN=t1; Path=/": "stored",
|
||||
"XSRF-TOKEN=t2; Path=/; SameSite=None": "REJECTED XSRF-TOKEN: SameSite=None and no Secure attribute - RFC 6265bis 5.5",
|
||||
"XSRF-TOKEN=t3; Path=/; Secure; SameSite=None": "stored"
|
||||
},
|
||||
"sentOnSameSiteRequest": "JSESSIONID=s3; XSRF-TOKEN=t3",
|
||||
"sentOnCrossSiteTopLevelNavigation": "JSESSIONID=s3; XSRF-TOKEN=t3",
|
||||
"sentOnCrossSiteFetch": "JSESSIONID=s3; XSRF-TOKEN=t3"
|
||||
}
|
||||
|
||||
# Read the second block first: over a trustworthy origin, the only two of the six
|
||||
# that reach a cross-site fetch are the two carrying Secure AND SameSite=None.
|
||||
# Then read the first: over plain http, none do -
|
||||
# which is why a cross-site SPA cannot be developed against http://127.0.0.1.
|
||||
# (http://localhost itself is treated as trustworthy by current browsers; a bare IP
|
||||
# is not.)
|
||||
34
cors-csrf/docs/output/13-tests.txt
Normal file
34
cors-csrf/docs/output/13-tests.txt
Normal file
@@ -0,0 +1,34 @@
|
||||
==============================================================================
|
||||
docs/output/13-tests.txt
|
||||
mvn -B test
|
||||
==============================================================================
|
||||
|
||||
09:33:03.378 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper -- Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CsrfAndCookieTests$Naive
|
||||
09:33:03.464 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper -- Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CsrfAndCookieTests$Naive
|
||||
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.904 s -- in CookieCsrfTokenRepository.withHttpOnlyFalse() on its own
|
||||
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CsrfAndCookieTests$Spa
|
||||
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CsrfAndCookieTests$Spa
|
||||
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.444 s -- in csrf.spa()
|
||||
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.028 s -- in SpecCookieJar - the storage and sending rules a browser applies
|
||||
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CsrfAndCookieTests$Ordering
|
||||
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CsrfAndCookieTests$Ordering
|
||||
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.362 s -- in csrfTokenRepository(..) before spa()
|
||||
[INFO] Tests run: 0, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.783 s -- in com.ankurm.cors.CsrfAndCookieTests
|
||||
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$MvcOnly
|
||||
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$MvcOnly
|
||||
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.305 s -- in CORS on the MVC layer only
|
||||
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$Misnamed
|
||||
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$Misnamed
|
||||
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.264 s -- in the right type under the wrong bean name
|
||||
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$SecuritySource
|
||||
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$SecuritySource
|
||||
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.225 s -- in a bean named corsConfigurationSource
|
||||
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$TwoSources
|
||||
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$TwoSources
|
||||
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.235 s -- in two UrlBasedCorsConfigurationSource beans
|
||||
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$MvcBridge
|
||||
<timestamp> INFO <pid> --- [cors-csrf-samesite] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.cors.CorsCsrfApplication for test class com.ankurm.cors.CorsContractTests$MvcBridge
|
||||
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.217 s -- in the same MVC configuration plus .cors(withDefaults())
|
||||
[INFO] Tests run: 0, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.256 s -- in com.ankurm.cors.CorsContractTests
|
||||
[INFO] Tests run: 23, Failures: 0, Errors: 0, Skipped: 0
|
||||
[INFO] BUILD SUCCESS
|
||||
55
cors-csrf/pom.xml
Normal file
55
cors-csrf/pom.xml
Normal file
@@ -0,0 +1,55 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<!-- Inheriting spring-boot-starter-parent (rather than importing the BOM) so this module
|
||||
gets Boot's own compiler settings. Every version below is managed by the parent.
|
||||
This module is a real servlet application because the thing it demonstrates - the
|
||||
order in which the CORS and CSRF filters run relative to authorization - only exists
|
||||
inside a servlet container. See docs/01-two-layers.md. -->
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.1.1</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>cors-csrf-samesite</artifactId>
|
||||
<version>1.0</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
25
cors-csrf/scripts/preflight.sh
Executable file
25
cors-csrf/scripts/preflight.sh
Executable file
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# Send one CORS preflight and print the status line and the headers that decide the outcome.
|
||||
#
|
||||
# ./scripts/preflight.sh https://spa.example.com POST /api/data
|
||||
#
|
||||
# A preflight is not a special kind of request. It is an OPTIONS carrying Origin and
|
||||
# Access-Control-Request-Method, and it is sent WITHOUT cookies or an Authorization header -
|
||||
# which is precisely why a chain that requires authentication rejects it.
|
||||
set -eu
|
||||
ORIGIN="${1:-https://spa.example.com}"
|
||||
METHOD="${2:-POST}"
|
||||
PATH_="${3:-/api/data}"
|
||||
|
||||
echo "\$ curl -s -i -X OPTIONS http://localhost:8080$PATH_ \\"
|
||||
echo " -H 'Origin: $ORIGIN' \\"
|
||||
echo " -H 'Access-Control-Request-Method: $METHOD' \\"
|
||||
echo " -H 'Access-Control-Request-Headers: content-type,x-xsrf-token'"
|
||||
echo
|
||||
curl -s -i -X OPTIONS "http://localhost:8080$PATH_" \
|
||||
-H "Origin: $ORIGIN" \
|
||||
-H "Access-Control-Request-Method: $METHOD" \
|
||||
-H "Access-Control-Request-Headers: content-type,x-xsrf-token" \
|
||||
| sed -n '1,/^\r$/p' \
|
||||
| grep -viE '^(date|keep-alive|connection|content-length|transfer-encoding):' \
|
||||
| sed 's/\r$//'
|
||||
355
cors-csrf/scripts/run-all.sh
Executable file
355
cors-csrf/scripts/run-all.sh
Executable file
@@ -0,0 +1,355 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerates every file under docs/output/ from a real run. Nothing in docs/output/ is
|
||||
# hand-written; if a number in the article disagrees with a file here, the file is right.
|
||||
#
|
||||
# ./scripts/run-all.sh
|
||||
#
|
||||
# Takes a few minutes: the application restarts once per scenario, because the scenarios are
|
||||
# Spring profiles and profiles are fixed at context startup.
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
OUT=docs/output
|
||||
mkdir -p "$OUT"
|
||||
|
||||
hdr() { printf '%s\n%s\n%s\n\n' "$(printf '=%.0s' $(seq 1 78))" "$1" "$(printf '=%.0s' $(seq 1 78))"; }
|
||||
|
||||
# Strip run-to-run noise so committed files diff cleanly.
|
||||
scrub() {
|
||||
sed -E \
|
||||
-e 's/\r$//' \
|
||||
-e 's/[0-9]{4}-[0-9]{2}-[0-9]{2}[T ][0-9:.]+(Z|\+[0-9:]+)?/<timestamp>/g' \
|
||||
-e 's/(JSESSIONID=)[0-9A-F]+/\1<session>/g' \
|
||||
-e 's/(XSRF-TOKEN=|MY-CSRF=)[0-9a-f-]{36}/\1<token>/g' \
|
||||
-e 's/(X-XSRF-TOKEN: |X-CSRF-TOKEN: )[0-9a-f-]{36}/\1<token>/g' \
|
||||
-e '/^(Date|Keep-Alive|Connection|Content-Length|Transfer-Encoding|Expires):/d' \
|
||||
-e 's/PID [0-9]+/PID <pid>/g' \
|
||||
-e 's/in [0-9.]+ seconds \(process running for [0-9.]+\)/in <n> seconds/g' \
|
||||
-e 's/ [0-9]+ --- / <pid> --- /g' \
|
||||
-e 's/\[nio-8080-exec-[0-9]+\]/[nio-8080-exec-N]/g' \
|
||||
-e '/Picked up JAVA_TOOL_OPTIONS/d' \
|
||||
| cat -s
|
||||
}
|
||||
|
||||
headers() { # headers <curl args...>
|
||||
curl -s -i "$@" | sed -n '1,/^\r$/p' | grep -viE '^(date|keep-alive|connection|content-length|transfer-encoding|content-type|content-language):'
|
||||
}
|
||||
|
||||
logs_since() { # logs_since <marker-line-count> <grep-pattern>
|
||||
sed -n "$(( $1 + 1 )),\$p" /tmp/cors-csrf-app.log | grep -E "$2" || true
|
||||
}
|
||||
|
||||
########################################################################################
|
||||
# 1. CORS on the MVC layer only - the preflight never reaches the servlet
|
||||
########################################################################################
|
||||
./scripts/run.sh mvconly > /dev/null
|
||||
{
|
||||
hdr "docs/output/01-mvc-only.txt
|
||||
CORS configured with WebMvcConfigurer.addCorsMappings and nothing else.
|
||||
Profile: mvconly"
|
||||
echo "# The security chain. Note what is NOT in it."
|
||||
echo "\$ curl -s localhost:8080/diag/chain"
|
||||
curl -s localhost:8080/diag/chain | python3 -m json.tool
|
||||
echo
|
||||
echo "# CorsConfigurationSource beans in the context."
|
||||
echo "\$ curl -s localhost:8080/diag/cors-sources"
|
||||
curl -s localhost:8080/diag/cors-sources | python3 -m json.tool
|
||||
echo
|
||||
./scripts/preflight.sh
|
||||
echo
|
||||
echo "# The MVC CORS mapping is real - it just never runs, because the request is"
|
||||
echo "# rejected at AuthorizationFilter (order 4200) and the DispatcherServlet is"
|
||||
echo "# downstream of the entire filter chain."
|
||||
} | scrub > "$OUT/01-mvc-only.txt"
|
||||
|
||||
########################################################################################
|
||||
# 2. The same MVC configuration, with .cors(withDefaults()) added
|
||||
########################################################################################
|
||||
./scripts/run.sh mvcbridge > /dev/null
|
||||
{
|
||||
hdr "docs/output/02-mvc-bridge.txt
|
||||
The identical MVC CORS mapping plus one line: .cors(Customizer.withDefaults()).
|
||||
Profile: mvcbridge"
|
||||
echo "\$ curl -s localhost:8080/diag/chain"
|
||||
curl -s localhost:8080/diag/chain | python3 -m json.tool
|
||||
echo
|
||||
./scripts/preflight.sh
|
||||
echo
|
||||
echo "# CorsFilter is now in the chain at order 1000, between HeaderWriterFilter (900)"
|
||||
echo "# and CsrfFilter (1100), and it short-circuits the preflight before authorization"
|
||||
echo "# ever sees it. Note Access-Control-Max-Age: 1800 - that default comes from MVC's"
|
||||
echo "# CorsRegistration, not from CorsConfiguration."
|
||||
} | scrub > "$OUT/02-mvc-bridge.txt"
|
||||
|
||||
########################################################################################
|
||||
# 3. A CorsConfigurationSource bean, correctly named
|
||||
########################################################################################
|
||||
./scripts/run.sh securitysource > /dev/null
|
||||
{
|
||||
hdr "docs/output/03-security-source.txt
|
||||
A @Bean named corsConfigurationSource. .cors(..) is never called - it is applied for us.
|
||||
Profile: securitysource"
|
||||
echo "\$ curl -s localhost:8080/diag/cors-sources"
|
||||
curl -s localhost:8080/diag/cors-sources | python3 -m json.tool
|
||||
echo
|
||||
./scripts/preflight.sh
|
||||
echo
|
||||
echo "# Compare with 02: there is no Access-Control-Max-Age here. CorsConfiguration"
|
||||
echo "# leaves maxAge null, so every single cross-origin call re-runs the preflight."
|
||||
} | scrub > "$OUT/03-security-source.txt"
|
||||
|
||||
########################################################################################
|
||||
# 4. Three rejections that look identical from the client
|
||||
########################################################################################
|
||||
{
|
||||
hdr "docs/output/04-three-identical-403s.txt
|
||||
Origin not allowed, method not allowed, header not allowed. One status, one shape.
|
||||
Profile: securitysource, CORS_LOG_LEVEL=DEBUG"
|
||||
} > "$OUT/04-three-identical-403s.txt"
|
||||
CORS_LOG_LEVEL=DEBUG ./scripts/run.sh securitysource > /dev/null
|
||||
MARK=$(wc -l < /tmp/cors-csrf-app.log)
|
||||
{
|
||||
echo "# 1. disallowed origin"
|
||||
headers -X OPTIONS localhost:8080/api/data -H 'Origin: https://evil.example.com' -H 'Access-Control-Request-Method: POST'
|
||||
echo "# 2. disallowed method"
|
||||
headers -X OPTIONS localhost:8080/api/data -H 'Origin: https://spa.example.com' -H 'Access-Control-Request-Method: DELETE'
|
||||
echo "# 3. disallowed request header"
|
||||
headers -X OPTIONS localhost:8080/api/data -H 'Origin: https://spa.example.com' -H 'Access-Control-Request-Method: POST' -H 'Access-Control-Request-Headers: authorization'
|
||||
echo "# body of a rejected preflight:"
|
||||
curl -s -X OPTIONS localhost:8080/api/data -H 'Origin: https://evil.example.com' -H 'Access-Control-Request-Method: POST'
|
||||
echo
|
||||
echo
|
||||
echo "# The only thing that distinguishes them is a DEBUG line from DefaultCorsProcessor:"
|
||||
sleep 1
|
||||
logs_since "$MARK" 'DefaultCorsProcessor'
|
||||
} | scrub >> "$OUT/04-three-identical-403s.txt"
|
||||
|
||||
########################################################################################
|
||||
# 5. The bean-name trap: right type, wrong name
|
||||
########################################################################################
|
||||
CORS_LOG_LEVEL=DEBUG ./scripts/run.sh misnamed > /dev/null
|
||||
MARK=$(wc -l < /tmp/cors-csrf-app.log)
|
||||
{
|
||||
hdr "docs/output/05-misnamed-bean.txt
|
||||
The same UrlBasedCorsConfigurationSource bean, named apiCorsSource instead of
|
||||
corsConfigurationSource. It starts. The preflight returns 200. It carries no CORS headers.
|
||||
Profile: misnamed"
|
||||
echo "\$ curl -s localhost:8080/diag/cors-sources"
|
||||
curl -s localhost:8080/diag/cors-sources | python3 -m json.tool
|
||||
echo
|
||||
./scripts/preflight.sh
|
||||
echo
|
||||
sleep 1
|
||||
logs_since "$MARK" 'DefaultCorsProcessor'
|
||||
echo
|
||||
echo "# Two different lookups. HttpSecurityConfiguration.applyCorsIfAvailable asks"
|
||||
echo "# getBeanNamesForType(UrlBasedCorsConfigurationSource.class) and enables CORS if the"
|
||||
echo "# array is non-empty, so the bean above DID switch the configurer on."
|
||||
echo "# CorsConfigurer.getCorsConfigurationSource then asks"
|
||||
echo "# containsBeanDefinition(\"corsConfigurationSource\"), which is false, and falls back"
|
||||
echo "# to Spring MVC's registrations - of which there are none."
|
||||
echo "# CorsFilter returns from every preflight whether or not it found a configuration:"
|
||||
echo "# if (!isValid || CorsUtils.isPreFlightRequest(request)) { return; }"
|
||||
echo "# so the OPTIONS never reaches AuthorizationFilter and the client gets a bare 200."
|
||||
} | scrub > "$OUT/05-misnamed-bean.txt"
|
||||
|
||||
########################################################################################
|
||||
# 6. Two sources - the documentation says CORS is not configured. It is.
|
||||
########################################################################################
|
||||
CORS_LOG_LEVEL=DEBUG ./scripts/run.sh twosources > /dev/null
|
||||
MARK=$(wc -l < /tmp/cors-csrf-app.log)
|
||||
{
|
||||
hdr "docs/output/06-two-sources.txt
|
||||
Two UrlBasedCorsConfigurationSource beans. The reference documentation says Spring Security
|
||||
'won't automatically configure CORS support for you, because it cannot decide which one to
|
||||
use'. In 7.1.1 it configures it, and the bean NAME decides.
|
||||
Profile: twosources"
|
||||
curl -s localhost:8080/diag/cors-sources | python3 -m json.tool
|
||||
echo
|
||||
echo "# the origin allowed by the bean named corsConfigurationSource:"
|
||||
headers -X OPTIONS localhost:8080/api/data -H 'Origin: https://spa.example.com' -H 'Access-Control-Request-Method: POST' -H 'Access-Control-Request-Headers: content-type'
|
||||
echo "# the origin allowed by adminCorsSource, which is never consulted:"
|
||||
headers -X OPTIONS localhost:8080/api/data -H 'Origin: https://admin.example.com' -H 'Access-Control-Request-Method: POST' -H 'Access-Control-Request-Headers: content-type'
|
||||
sleep 1
|
||||
logs_since "$MARK" 'DefaultCorsProcessor'
|
||||
} | scrub > "$OUT/06-two-sources.txt"
|
||||
|
||||
########################################################################################
|
||||
# 7. allowedOrigins("*") with allowCredentials(true)
|
||||
########################################################################################
|
||||
./scripts/run.sh wildcard > /dev/null
|
||||
MARK=$(wc -l < /tmp/cors-csrf-app.log)
|
||||
{
|
||||
hdr "docs/output/07-wildcard-credentials.txt
|
||||
allowedOrigins(\"*\") together with allowCredentials(true). Legal to configure, illegal to
|
||||
serve. The failure is thrown on the request, not at startup - and it does not surface as a 500.
|
||||
Profile: wildcard"
|
||||
headers -X OPTIONS localhost:8080/api/data -H 'Origin: https://spa.example.com' -H 'Access-Control-Request-Method: POST'
|
||||
echo "# and a plain authenticated GET, with correct credentials:"
|
||||
headers -u alice:password localhost:8080/api/data -H 'Origin: https://spa.example.com'
|
||||
echo
|
||||
sleep 1
|
||||
logs_since "$MARK" 'IllegalArgumentException: When allowCredentials|at org.springframework.web.cors' | head -5
|
||||
echo
|
||||
echo "# 401, not 500. The exception escapes CorsFilter, Tomcat re-dispatches to /error,"
|
||||
echo "# the security chain runs again on that dispatch without re-reading the credential,"
|
||||
echo "# and the anonymous second pass is what answers."
|
||||
} | scrub > "$OUT/07-wildcard-credentials.txt"
|
||||
|
||||
########################################################################################
|
||||
# 8. CSRF for a SPA: the pre-6.0 recipe
|
||||
########################################################################################
|
||||
CSRF_LOG_LEVEL=DEBUG ./scripts/run.sh csrfnaive > /dev/null
|
||||
MARK=$(wc -l < /tmp/cors-csrf-app.log)
|
||||
J=$(mktemp); rm -f "$J"
|
||||
{
|
||||
hdr "docs/output/08-csrf-naive.txt
|
||||
CookieCsrfTokenRepository.withHttpOnlyFalse() on its own - the recipe from every pre-6.0
|
||||
tutorial. Three separate things go wrong.
|
||||
Profile: csrfnaive"
|
||||
echo "# 1. The bootstrap GET. A SPA expects an XSRF-TOKEN cookie here."
|
||||
headers -c "$J" -u alice:password localhost:8080/api/data
|
||||
echo "# cookie jar after the GET:"
|
||||
{ grep -v '^#' "$J" | sed 's/\t/ | /g' | grep . || echo "(empty - no cookie was set)"; }
|
||||
echo
|
||||
echo "# 2. POST with no token."
|
||||
headers -b "$J" -c "$J" -u alice:password -X POST localhost:8080/api/data -H 'Content-Type: application/json' -d '{}'
|
||||
echo "# cookie jar now:"
|
||||
grep -v '^#' "$J" | sed 's/\t/ | /g'
|
||||
echo
|
||||
echo "# 3. POST echoing the raw cookie value back in X-XSRF-TOKEN, which is what every"
|
||||
echo "# SPA snippet on the internet does."
|
||||
TOK=$(grep XSRF-TOKEN "$J" | awk '{print $NF}')
|
||||
headers -b "$J" -u alice:password -X POST localhost:8080/api/data -H "X-XSRF-TOKEN: $TOK" -H 'Content-Type: application/json' -d '{}'
|
||||
echo
|
||||
sleep 1
|
||||
logs_since "$MARK" 'CsrfFilter'
|
||||
} | scrub > "$OUT/08-csrf-naive.txt"
|
||||
|
||||
########################################################################################
|
||||
# 9. The same failure with /error permitted - the status the SPA never sees
|
||||
########################################################################################
|
||||
CSRF_LOG_LEVEL=DEBUG ./scripts/run.sh csrfnaive,errorpermit > /dev/null
|
||||
{
|
||||
hdr "docs/output/09-error-dispatch.txt
|
||||
The identical CSRF failure, with one extra filter chain that permits /error.
|
||||
Profile: csrfnaive,errorpermit"
|
||||
headers -u alice:password -X POST localhost:8080/api/data -H 'Content-Type: application/json' -d '{}'
|
||||
curl -s -u alice:password -X POST localhost:8080/api/data -H 'Content-Type: application/json' -d '{}'
|
||||
echo
|
||||
echo
|
||||
echo "# 403, and a body. Without the /error chain the same request answers 401 with an"
|
||||
echo "# empty body and a WWW-Authenticate header - see 08. AccessDeniedHandlerImpl calls"
|
||||
echo "# response.sendError(403), the container re-dispatches to /error, and the security"
|
||||
echo "# chain runs a second time on that dispatch. BasicAuthenticationFilter extends"
|
||||
echo "# OncePerRequestFilter and skips error dispatches, so the second pass is anonymous"
|
||||
echo "# and AuthorizationFilter answers 401 over the top of the 403."
|
||||
} | scrub > "$OUT/09-error-dispatch.txt"
|
||||
|
||||
########################################################################################
|
||||
# 10. csrf.spa()
|
||||
########################################################################################
|
||||
./scripts/run.sh csrfspa > /dev/null
|
||||
J=$(mktemp); rm -f "$J"
|
||||
{
|
||||
hdr "docs/output/10-csrf-spa.txt
|
||||
The same flow under csrf.spa(), added in Spring Security 7.0.
|
||||
Profile: csrfspa"
|
||||
echo "# 1. The bootstrap GET now DOES set the cookie."
|
||||
headers -c "$J" -u alice:password localhost:8080/api/data
|
||||
echo
|
||||
echo "# 2. POST with no token still fails, as it must."
|
||||
headers -b "$J" -u alice:password -X POST localhost:8080/api/data -H 'Content-Type: application/json' -d '{}'
|
||||
echo
|
||||
echo "# 3. POST echoing the raw cookie value in X-XSRF-TOKEN."
|
||||
TOK=$(grep XSRF-TOKEN "$J" | awk '{print $NF}')
|
||||
headers -b "$J" -u alice:password -X POST localhost:8080/api/data -H "X-XSRF-TOKEN: $TOK" -H 'Content-Type: application/json' -d '{}'
|
||||
curl -s -b "$J" -u alice:password -X POST localhost:8080/api/data -H "X-XSRF-TOKEN: $TOK" -H 'Content-Type: application/json' -d '{}'
|
||||
echo
|
||||
echo
|
||||
echo "# Note the cookie attributes: Path=/ and nothing else. No SameSite, no Secure,"
|
||||
echo "# no HttpOnly. A cookie with no SameSite attribute is treated as Lax, so a"
|
||||
echo "# genuinely cross-site SPA still never receives it. See 12."
|
||||
} | scrub > "$OUT/10-csrf-spa.txt"
|
||||
|
||||
########################################################################################
|
||||
# 11. spa() discards a repository configured before it
|
||||
########################################################################################
|
||||
./scripts/run.sh spaorder > /dev/null
|
||||
J=$(mktemp); rm -f "$J"
|
||||
{
|
||||
hdr "docs/output/11-spa-ordering.txt
|
||||
.csrf(c -> c.csrfTokenRepository(custom).spa()) - the custom repository asked for a cookie
|
||||
named MY-CSRF and a header named X-CSRF-TOKEN. Neither reaches the running application.
|
||||
Profile: spaorder"
|
||||
headers -c "$J" -u alice:password localhost:8080/api/data
|
||||
echo "# cookie jar:"
|
||||
grep -v '^#' "$J" | sed 's/\t/ | /g'
|
||||
echo
|
||||
echo "# spa() assigns csrfTokenRepository and requestHandler unconditionally; it is not a"
|
||||
echo "# 'defaults if unset' method. Swap the two calls and MY-CSRF appears."
|
||||
} | scrub > "$OUT/11-spa-ordering.txt"
|
||||
|
||||
########################################################################################
|
||||
# 12. SameSite - what is actually written, and what a browser does with it
|
||||
########################################################################################
|
||||
{
|
||||
hdr "docs/output/12-samesite.txt
|
||||
The Set-Cookie headers this application emits under four configurations, and what
|
||||
SpecCookieJar - a model of RFC 6265bis 5.5 and 5.8.3 - does with them."
|
||||
} > "$OUT/12-samesite.txt"
|
||||
|
||||
emit() { # emit <label> <env...>
|
||||
local label="$1"; shift
|
||||
env "$@" ./scripts/run.sh "$PROFILE" > /dev/null
|
||||
echo "## $label"
|
||||
curl -s -D- -o /dev/null -u alice:password localhost:8080/api/data | grep -i '^set-cookie' | sed 's/\r$//'
|
||||
curl -s -D- -o /dev/null localhost:8080/api/data | grep -i '^set-cookie' | grep -i jsessionid | sed 's/\r$//' || true
|
||||
echo
|
||||
}
|
||||
{
|
||||
PROFILE=csrfspa
|
||||
emit "csrf.spa() defaults, session cookie left at same-site=lax" SESSION_SAME_SITE=lax SESSION_SECURE=false
|
||||
emit "session cookie set to same-site=none, secure=false" SESSION_SAME_SITE=none SESSION_SECURE=false
|
||||
PROFILE=crosssite
|
||||
emit "crosssite profile: SameSite=None and Secure on both cookies" SESSION_SAME_SITE=none SESSION_SECURE=true
|
||||
emit "crosssite profile with -DOMIT_SECURE=true" JVM_ARGS=-DOMIT_SECURE=true SESSION_SAME_SITE=none SESSION_SECURE=false
|
||||
} | scrub >> "$OUT/12-samesite.txt"
|
||||
|
||||
./scripts/run.sh csrfspa > /dev/null
|
||||
{
|
||||
echo "## The same headers, run through SpecCookieJar"
|
||||
python3 - <<'PY'
|
||||
import urllib.parse, urllib.request, json
|
||||
headers = [
|
||||
"JSESSIONID=s1; Path=/; HttpOnly; SameSite=Lax",
|
||||
"JSESSIONID=s2; Path=/; HttpOnly; SameSite=None",
|
||||
"JSESSIONID=s3; Path=/; Secure; HttpOnly; SameSite=None",
|
||||
"XSRF-TOKEN=t1; Path=/",
|
||||
"XSRF-TOKEN=t2; Path=/; SameSite=None",
|
||||
"XSRF-TOKEN=t3; Path=/; Secure; SameSite=None",
|
||||
]
|
||||
query = "&".join("h=" + urllib.parse.quote(h) for h in headers)
|
||||
for secure in ("false", "true"):
|
||||
url = f"http://localhost:8080/diag/cookie-spec?{query}&secure={secure}"
|
||||
print(json.dumps(json.load(urllib.request.urlopen(url)), indent=2))
|
||||
print()
|
||||
PY
|
||||
echo "# Read the second block first: over a trustworthy origin, the only two of the six"
|
||||
echo "# that reach a cross-site fetch are the two carrying Secure AND SameSite=None."
|
||||
echo "# Then read the first: over plain http, none do -"
|
||||
echo "# which is why a cross-site SPA cannot be developed against http://127.0.0.1."
|
||||
echo "# (http://localhost itself is treated as trustworthy by current browsers; a bare IP"
|
||||
echo "# is not.)"
|
||||
} | scrub >> "$OUT/12-samesite.txt"
|
||||
|
||||
########################################################################################
|
||||
# 13. The assertions
|
||||
########################################################################################
|
||||
{
|
||||
hdr "docs/output/13-tests.txt
|
||||
mvn -B test"
|
||||
(cd . && mvn -B test 2>&1) | grep -E 'Tests run|ERROR|BUILD|CorsCsrf' | head -30
|
||||
} | scrub > "$OUT/13-tests.txt"
|
||||
|
||||
./scripts/stop.sh
|
||||
echo "regenerated $(ls "$OUT" | wc -l) files under $OUT"
|
||||
37
cors-csrf/scripts/run.sh
Executable file
37
cors-csrf/scripts/run.sh
Executable file
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
# Start the demo application under a given profile and wait until it answers.
|
||||
#
|
||||
# ./scripts/run.sh securitysource
|
||||
# ./scripts/run.sh mvconly
|
||||
# SESSION_SAME_SITE=none SESSION_SECURE=false ./scripts/run.sh crosssite
|
||||
# JVM_ARGS=-DOMIT_SECURE=true ./scripts/run.sh crosssite
|
||||
# CSRF_LOG_LEVEL=DEBUG ./scripts/run.sh csrfnaive
|
||||
#
|
||||
# Two profiles are expected to FAIL to start - `misnamed` and `preflightclash`. That is what
|
||||
# they demonstrate, so this script returns 1 for them and the transcript keeps the exception.
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
PROFILE="${1:-securitysource}"
|
||||
LOG="${LOG:-/tmp/cors-csrf-app.log}"
|
||||
|
||||
./scripts/stop.sh
|
||||
|
||||
setsid nohup mvn -B org.springframework.boot:spring-boot-maven-plugin:run \
|
||||
-Dspring-boot.run.profiles="$PROFILE" \
|
||||
-Dspring-boot.run.jvmArguments="${JVM_ARGS:-}" \
|
||||
> "$LOG" 2>&1 < /dev/null &
|
||||
|
||||
for _ in $(seq 1 90); do
|
||||
if curl -sf -o /dev/null http://localhost:8080/diag/chain 2>/dev/null; then
|
||||
echo "started with profile: $PROFILE (log: $LOG)"
|
||||
exit 0
|
||||
fi
|
||||
if grep -q 'APPLICATION FAILED TO START' "$LOG" 2>/dev/null; then
|
||||
echo "application failed to start under profile: $PROFILE (log: $LOG)" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "application did not become ready; see $LOG" >&2
|
||||
exit 1
|
||||
11
cors-csrf/scripts/stop.sh
Executable file
11
cors-csrf/scripts/stop.sh
Executable file
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stop the demo application.
|
||||
#
|
||||
# Note the bracket in the grep pattern: it stops the pattern matching this script's own
|
||||
# process. Match the MAIN CLASS, never 'spring-boot' - that pattern also matches the shell
|
||||
# command line that started the application, so pkill -f 'spring-boot' kills your own shell.
|
||||
set -eu
|
||||
for pid in $(ps -eo pid,cmd | grep '[C]orsCsrfApplication' | awk '{print $1}'); do
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
done
|
||||
sleep 1
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.ankurm.cors;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* Companion application for
|
||||
* <a href="https://ankurm.com/spring-boot-4-cors-csrf-samesite/">CORS, CSRF and SameSite in
|
||||
* Spring Boot 4</a>.
|
||||
*
|
||||
* <p>Every scenario in the article is a Spring profile on this one application. Start it with
|
||||
* {@code ./scripts/run.sh <profile>} and drive it with {@code curl}; nothing here needs a
|
||||
* browser, because a preflight request is just an {@code OPTIONS} with two headers.
|
||||
*
|
||||
* <p>See <a href="../../../../docs/01-two-layers.md">docs/01-two-layers.md</a> for why the same
|
||||
* CORS configuration behaves differently depending on which layer you put it on.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class CorsCsrfApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(CorsCsrfApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.ankurm.cors.config;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
/**
|
||||
* Profile {@code csrfnaive}: {@code CookieCsrfTokenRepository.withHttpOnlyFalse()} on its own.
|
||||
*
|
||||
* <p>This is the recipe in every SPA tutorial written before Spring Security 6, and since 6.0
|
||||
* it produces a 403 on the first POST. The default {@code CsrfTokenRequestHandler} is
|
||||
* {@code XorCsrfTokenRequestAttributeHandler}: the value written into the {@code XSRF-TOKEN}
|
||||
* cookie is XOR-masked against a per-response random, so the raw cookie value the SPA reads and
|
||||
* echoes back in {@code X-XSRF-TOKEN} is not the value the server compares against.
|
||||
*
|
||||
* <p>Two more things go wrong here and both are visible in the transcripts:
|
||||
* the token is <em>deferred</em>, so a plain {@code GET} does not set the cookie at all unless
|
||||
* something dereferences the token; and the cookie carries no {@code SameSite} attribute, which
|
||||
* browsers treat as {@code Lax}, so a cross-site SPA never receives it. See
|
||||
* <a href="../../../../docs/06-csrf-for-spas.md">docs/06-csrf-for-spas.md</a>.
|
||||
*/
|
||||
@Configuration
|
||||
@Profile("csrfnaive")
|
||||
public class CsrfNaiveConfig {
|
||||
|
||||
@Bean
|
||||
UrlBasedCorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOrigins(List.of("https://spa.example.com"));
|
||||
configuration.setAllowedMethods(List.of("GET", "POST"));
|
||||
configuration.setAllowedHeaders(List.of("Content-Type", "X-XSRF-TOKEN"));
|
||||
configuration.setAllowCredentials(true);
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/api/**", configuration);
|
||||
return source;
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Order(1)
|
||||
SecurityFilterChain chain(HttpSecurity http) throws Exception {
|
||||
return http
|
||||
.authorizeHttpRequests((auth) -> auth
|
||||
.requestMatchers("/diag/**").permitAll()
|
||||
.anyRequest().authenticated())
|
||||
.httpBasic(Customizer.withDefaults())
|
||||
.csrf((csrf) -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.ankurm.cors.config;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
/**
|
||||
* Profile {@code csrfspa}: {@code csrf.spa()}, added in Spring Security 7.0.
|
||||
*
|
||||
* <p>Disassembling {@code CsrfConfigurer.spa()} shows exactly two assignments: the repository
|
||||
* becomes {@code CookieCsrfTokenRepository.withHttpOnlyFalse()} and the request handler becomes
|
||||
* the package-private {@code SpaCsrfTokenRequestHandler}. That handler holds two delegates
|
||||
* — a plain {@code CsrfTokenRequestAttributeHandler} with
|
||||
* {@code setCsrfRequestAttributeName(null)}, and an {@code XorCsrfTokenRequestAttributeHandler}
|
||||
* — writes with the XOR one and, on resolve, uses the plain one whenever the request
|
||||
* carries the header. Header-carrying SPA requests compare raw values; form posts keep the
|
||||
* BREACH masking.
|
||||
*
|
||||
* <p>Because {@code spa()} assigns both fields unconditionally, calling
|
||||
* {@code csrfTokenRepository(..)} before it is silently discarded. See
|
||||
* <a href="../../../../docs/06-csrf-for-spas.md">docs/06-csrf-for-spas.md</a> and
|
||||
* {@link CsrfSpaOrderConfig}.
|
||||
*/
|
||||
@Configuration
|
||||
@Profile("csrfspa")
|
||||
public class CsrfSpaConfig {
|
||||
|
||||
@Bean
|
||||
UrlBasedCorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOrigins(List.of("https://spa.example.com"));
|
||||
configuration.setAllowedMethods(List.of("GET", "POST"));
|
||||
configuration.setAllowedHeaders(List.of("Content-Type", "X-XSRF-TOKEN"));
|
||||
configuration.setAllowCredentials(true);
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/api/**", configuration);
|
||||
return source;
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Order(1)
|
||||
SecurityFilterChain chain(HttpSecurity http) throws Exception {
|
||||
return http
|
||||
.authorizeHttpRequests((auth) -> auth
|
||||
.requestMatchers("/diag/**").permitAll()
|
||||
.anyRequest().authenticated())
|
||||
.httpBasic(Customizer.withDefaults())
|
||||
.csrf((csrf) -> csrf.spa())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.ankurm.cors.config;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
/**
|
||||
* Profile {@code crosssite}: what {@code spa()} still does not do for a genuinely cross-site
|
||||
* SPA, and the two cookie attributes you have to add yourself.
|
||||
*
|
||||
* <p>{@code spa()} leaves the {@code XSRF-TOKEN} cookie with no {@code SameSite} attribute
|
||||
* — {@code CookieCsrfTokenRepository}'s default cookie customizer is an empty lambda,
|
||||
* confirmed in the bytecode. A cookie with no {@code SameSite} is treated as {@code Lax}, so
|
||||
* it is not sent on a cross-site {@code fetch}. Setting {@code SameSite=None} without
|
||||
* {@code Secure} does not help either: the browser rejects the whole {@code Set-Cookie}
|
||||
* (RFC 6265bis §5.5). Both attributes are required, together.
|
||||
*
|
||||
* <p>The same applies to the session cookie, which is Boot's concern rather than Spring
|
||||
* Security's — see {@code application.yml} and
|
||||
* <a href="../../../../docs/07-samesite.md">docs/07-samesite.md</a>.
|
||||
*
|
||||
* <p>Run with {@code -DOMIT_SECURE=true} to emit {@code SameSite=None} <em>without</em>
|
||||
* {@code Secure} and watch {@code SpecCookieJar} reject it.
|
||||
*/
|
||||
@Configuration
|
||||
@Profile("crosssite")
|
||||
public class CsrfSpaCrossSiteConfig {
|
||||
|
||||
@Bean
|
||||
UrlBasedCorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOrigins(List.of("https://spa.example.com"));
|
||||
configuration.setAllowedMethods(List.of("GET", "POST"));
|
||||
configuration.setAllowedHeaders(List.of("Content-Type", "X-XSRF-TOKEN"));
|
||||
configuration.setAllowCredentials(true);
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/api/**", configuration);
|
||||
return source;
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Order(1)
|
||||
SecurityFilterChain chain(HttpSecurity http) throws Exception {
|
||||
boolean omitSecure = Boolean.getBoolean("OMIT_SECURE");
|
||||
CookieCsrfTokenRepository repository = CookieCsrfTokenRepository.withHttpOnlyFalse();
|
||||
repository.setCookieCustomizer((cookie) -> {
|
||||
cookie.sameSite("None");
|
||||
// The point of the flag: SameSite=None and Secure are a pair. Emitting one
|
||||
// without the other produces a Set-Cookie that every browser discards.
|
||||
cookie.secure(!omitSecure);
|
||||
});
|
||||
return http
|
||||
.authorizeHttpRequests((auth) -> auth
|
||||
.requestMatchers("/diag/**").permitAll()
|
||||
.anyRequest().authenticated())
|
||||
.httpBasic(Customizer.withDefaults())
|
||||
.csrf((csrf) -> csrf.spa().csrfTokenRepository(repository))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.ankurm.cors.config;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
/**
|
||||
* Profile {@code spaorder}: {@code csrfTokenRepository(..)} placed <em>before</em>
|
||||
* {@code spa()}, which throws it away.
|
||||
*
|
||||
* <p>{@code spa()} is not a "defaults if unset" method. Its two statements are unconditional
|
||||
* field assignments, so the custom cookie name below never reaches the running application and
|
||||
* the SPA gets a 403 while looking at a configuration that appears to say otherwise. Swap the
|
||||
* two calls and it works. {@link CsrfSpaCrossSiteConfig} relies on that ordering.
|
||||
*
|
||||
* <p>This is <a href="https://github.com/spring-projects/spring-security/issues/18718">
|
||||
* spring-security#18718</a>, and the surprising part is that the fix is a reordering rather
|
||||
* than a different API.
|
||||
*/
|
||||
@Configuration
|
||||
@Profile("spaorder")
|
||||
public class CsrfSpaOrderConfig {
|
||||
|
||||
@Bean
|
||||
UrlBasedCorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOrigins(List.of("https://spa.example.com"));
|
||||
configuration.setAllowedMethods(List.of("GET", "POST"));
|
||||
configuration.setAllowedHeaders(List.of("Content-Type", "X-XSRF-TOKEN", "X-CSRF-TOKEN"));
|
||||
configuration.setAllowCredentials(true);
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/api/**", configuration);
|
||||
return source;
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Order(1)
|
||||
SecurityFilterChain chain(HttpSecurity http) throws Exception {
|
||||
CookieCsrfTokenRepository repository = CookieCsrfTokenRepository.withHttpOnlyFalse();
|
||||
repository.setCookieName("MY-CSRF");
|
||||
repository.setHeaderName("X-CSRF-TOKEN");
|
||||
return http
|
||||
.authorizeHttpRequests((auth) -> auth
|
||||
.requestMatchers("/diag/**").permitAll()
|
||||
.anyRequest().authenticated())
|
||||
.httpBasic(Customizer.withDefaults())
|
||||
// Deliberately the wrong way round.
|
||||
.csrf((csrf) -> csrf.csrfTokenRepository(repository).spa())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.ankurm.cors.config;
|
||||
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
/**
|
||||
* Add-on profile {@code errorpermit}: a first filter chain that matches {@code /error} and
|
||||
* permits everything, so the container's error dispatch stops rewriting the status code.
|
||||
*
|
||||
* <p>Without it, a rejection raised inside the chain calls {@code response.sendError(403, ..)},
|
||||
* Tomcat re-dispatches the request to {@code /error}, and the security chain runs a second time
|
||||
* on that dispatch. {@code BasicAuthenticationFilter} extends {@code OncePerRequestFilter} and
|
||||
* skips error dispatches, so the credential is never re-read and the second pass is anonymous.
|
||||
* {@code AuthorizationFilter} then denies it and the client receives <b>401</b> — the
|
||||
* original 403 is gone.
|
||||
*
|
||||
* <p>Combine it with any other profile: {@code ./scripts/run.sh csrfnaive,errorpermit}.
|
||||
* See <a href="../../../../docs/05-the-error-dispatch.md">docs/05-the-error-dispatch.md</a>, and
|
||||
* <a href="https://ankurm.com/spring-security-filter-chain-explained/">The Spring Security
|
||||
* Filter Chain Explained</a> for the mechanism in full.
|
||||
*/
|
||||
@Configuration
|
||||
@Profile("errorpermit")
|
||||
public class ErrorDispatchConfig {
|
||||
|
||||
@Bean
|
||||
@Order(0)
|
||||
SecurityFilterChain errorChain(HttpSecurity http) throws Exception {
|
||||
return http
|
||||
.securityMatcher("/error")
|
||||
.authorizeHttpRequests((auth) -> auth.anyRequest().permitAll())
|
||||
.csrf((csrf) -> csrf.disable())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.ankurm.cors.config;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
/**
|
||||
* Profile {@code misnamed}: the same bean as {@link SecuritySourceConfig}, under a different
|
||||
* name. This is the gap between the two lookups.
|
||||
*
|
||||
* <p>{@code HttpSecurityConfiguration.applyCorsIfAvailable} asks
|
||||
* {@code getBeanNamesForType(UrlBasedCorsConfigurationSource.class)} and enables the CORS
|
||||
* configurer if the array is non-empty — so the bean below <em>does</em> switch CORS on.
|
||||
* {@code CorsConfigurer.getCorsConfigurationSource} then asks
|
||||
* {@code containsBeanDefinition("corsConfigurationSource")}, which is false, and falls through
|
||||
* to Spring MVC's registrations. There are none, so startup fails with
|
||||
* {@code NoSuchBeanDefinitionException}.
|
||||
*
|
||||
* <p>The message it prints names three fixes and does not mention the one that applies:
|
||||
* rename your bean.
|
||||
*/
|
||||
@Configuration
|
||||
@Profile("misnamed")
|
||||
public class MisnamedSourceConfig {
|
||||
|
||||
@Bean
|
||||
UrlBasedCorsConfigurationSource apiCorsSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOrigins(List.of("https://spa.example.com"));
|
||||
configuration.setAllowedMethods(List.of("GET", "POST"));
|
||||
configuration.setAllowCredentials(true);
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/api/**", configuration);
|
||||
return source;
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Order(1)
|
||||
SecurityFilterChain chain(HttpSecurity http) throws Exception {
|
||||
return http
|
||||
.authorizeHttpRequests((auth) -> auth
|
||||
.requestMatchers("/diag/**").permitAll()
|
||||
.anyRequest().authenticated())
|
||||
.httpBasic(Customizer.withDefaults())
|
||||
.csrf((csrf) -> csrf.disable())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.ankurm.cors.config;
|
||||
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
/**
|
||||
* Profile {@code mvcbridge}: the same MVC CORS mapping as {@code mvconly}, plus one line.
|
||||
*
|
||||
* <p>{@code .cors(Customizer.withDefaults())} makes {@code CorsConfigurer} run. With no bean
|
||||
* named {@code corsConfigurationSource} in the context it falls back to
|
||||
* {@code CorsConfigurer.MvcCorsFilter.getMvcCorsConfigurationSource(..)}, which reads the
|
||||
* registrations made by {@link MvcCorsConfig}. The resulting {@code CorsFilter} goes into the
|
||||
* chain at order 1000 — before {@code CsrfFilter} (1100) and a long way before
|
||||
* {@code AuthorizationFilter} (4200) — and short-circuits the preflight.
|
||||
*
|
||||
* <p>So MVC CORS configuration <em>can</em> drive the security layer. It just does not do so by
|
||||
* itself.
|
||||
*/
|
||||
@Configuration
|
||||
@Profile("mvcbridge")
|
||||
public class MvcBridgeSecurityConfig {
|
||||
|
||||
@Bean
|
||||
@Order(1)
|
||||
SecurityFilterChain chain(HttpSecurity http) throws Exception {
|
||||
return http
|
||||
.cors(Customizer.withDefaults())
|
||||
.authorizeHttpRequests((auth) -> auth
|
||||
.requestMatchers("/diag/**").permitAll()
|
||||
.anyRequest().authenticated())
|
||||
.httpBasic(Customizer.withDefaults())
|
||||
.csrf((csrf) -> csrf.disable())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.ankurm.cors.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
/**
|
||||
* CORS configured on the MVC layer — the first thing everybody tries, and the thing that
|
||||
* does not fix a preflight rejection on its own.
|
||||
*
|
||||
* <p>This registers a {@code CorsConfiguration} with Spring MVC's
|
||||
* {@code AbstractHandlerMapping}. It is consulted inside {@code DispatcherServlet}, which is
|
||||
* downstream of the entire security filter chain. If the preflight never reaches the servlet,
|
||||
* this configuration never runs. See
|
||||
* <a href="../../../../docs/01-two-layers.md">docs/01-two-layers.md</a>.
|
||||
*
|
||||
* <p>Active under the {@code mvconly} and {@code mvcbridge} profiles. The two profiles share
|
||||
* this file and differ only in whether the security chain enables CORS — which is the
|
||||
* whole point.
|
||||
*/
|
||||
@Configuration
|
||||
@Profile({ "mvconly", "mvcbridge" })
|
||||
public class MvcCorsConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/api/**")
|
||||
.allowedOrigins("https://spa.example.com")
|
||||
.allowedMethods("GET", "POST")
|
||||
.allowedHeaders("Content-Type", "X-XSRF-TOKEN")
|
||||
.allowCredentials(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.ankurm.cors.config;
|
||||
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
/**
|
||||
* Profile {@code mvconly}: MVC has a CORS mapping, the security chain does not.
|
||||
*
|
||||
* <p>There is no {@code UrlBasedCorsConfigurationSource} bean here, so Spring Security's
|
||||
* {@code HttpSecurityConfiguration.applyCorsIfAvailable} does not switch CORS on, so no
|
||||
* {@code CorsFilter} enters the chain. The preflight {@code OPTIONS} therefore travels the
|
||||
* whole chain and is judged by {@code AuthorizationFilter} at order 4200, which sees an
|
||||
* anonymous request and rejects it. The browser reports a CORS error; the server log shows an
|
||||
* authentication failure. Those are the same event.
|
||||
*
|
||||
* <p>Reproduce: {@code ./scripts/scenario-cors.sh mvconly}.
|
||||
*/
|
||||
@Configuration
|
||||
@Profile("mvconly")
|
||||
public class MvcOnlySecurityConfig {
|
||||
|
||||
@Bean
|
||||
@Order(1)
|
||||
SecurityFilterChain chain(HttpSecurity http) throws Exception {
|
||||
return http
|
||||
.authorizeHttpRequests((auth) -> auth
|
||||
.requestMatchers("/diag/**").permitAll()
|
||||
.anyRequest().authenticated())
|
||||
.httpBasic(Customizer.withDefaults())
|
||||
.csrf((csrf) -> csrf.disable())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.ankurm.cors.config;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
/**
|
||||
* Profile {@code securitysource}: the configuration that actually works, and the name that
|
||||
* makes it work.
|
||||
*
|
||||
* <p>The bean method is called {@code corsConfigurationSource} on purpose. That literal string
|
||||
* appears in {@code CorsConfigurer.getCorsConfigurationSource(..)} as a
|
||||
* {@code containsBeanDefinition} check. Rename this method and the behaviour changes —
|
||||
* see {@link MisnamedSourceConfig}.
|
||||
*
|
||||
* <p>Note also that {@code .cors(..)} is never called below. It does not need to be: with a
|
||||
* {@code UrlBasedCorsConfigurationSource} bean present, {@code HttpSecurityConfiguration}
|
||||
* applies the CORS configurer for you.
|
||||
*/
|
||||
@Configuration
|
||||
@Profile("securitysource")
|
||||
public class SecuritySourceConfig {
|
||||
|
||||
@Bean
|
||||
UrlBasedCorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOrigins(List.of("https://spa.example.com"));
|
||||
configuration.setAllowedMethods(List.of("GET", "POST"));
|
||||
configuration.setAllowedHeaders(List.of("Content-Type", "X-XSRF-TOKEN"));
|
||||
configuration.setAllowCredentials(true);
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/api/**", configuration);
|
||||
return source;
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Order(1)
|
||||
SecurityFilterChain chain(HttpSecurity http) throws Exception {
|
||||
return http
|
||||
.authorizeHttpRequests((auth) -> auth
|
||||
.requestMatchers("/diag/**").permitAll()
|
||||
.anyRequest().authenticated())
|
||||
.httpBasic(Customizer.withDefaults())
|
||||
.csrf((csrf) -> csrf.disable())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.ankurm.cors.config;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
/**
|
||||
* Profile {@code twosources}: two {@code UrlBasedCorsConfigurationSource} beans, one of which
|
||||
* carries the magic name.
|
||||
*
|
||||
* <p>The reference documentation says that with more than one such bean "Spring Security won't
|
||||
* automatically configure CORS support for you, because it cannot decide which one to use".
|
||||
* In 7.1.1 that is not what the bytecode does: {@code applyCorsIfAvailable} tests
|
||||
* {@code getBeanNamesForType(..).length} with {@code ifle}, i.e. "greater than zero", not
|
||||
* "exactly one". CORS is applied, and the bean named {@code corsConfigurationSource} wins.
|
||||
* {@code adminCorsSource} is never consulted on this chain.
|
||||
*
|
||||
* <p>Verified by {@code /diag/cors-sources} plus the preflight transcripts in
|
||||
* <a href="../../../../docs/output/">docs/output/</a>.
|
||||
*/
|
||||
@Configuration
|
||||
@Profile("twosources")
|
||||
public class TwoSourcesConfig {
|
||||
|
||||
@Bean
|
||||
UrlBasedCorsConfigurationSource corsConfigurationSource() {
|
||||
return sourceFor("https://spa.example.com");
|
||||
}
|
||||
|
||||
@Bean
|
||||
UrlBasedCorsConfigurationSource adminCorsSource() {
|
||||
return sourceFor("https://admin.example.com");
|
||||
}
|
||||
|
||||
private static UrlBasedCorsConfigurationSource sourceFor(String origin) {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOrigins(List.of(origin));
|
||||
configuration.setAllowedMethods(List.of("GET", "POST"));
|
||||
configuration.setAllowedHeaders(List.of("Content-Type", "X-XSRF-TOKEN"));
|
||||
configuration.setAllowCredentials(true);
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/api/**", configuration);
|
||||
return source;
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Order(1)
|
||||
SecurityFilterChain chain(HttpSecurity http) throws Exception {
|
||||
return http
|
||||
.authorizeHttpRequests((auth) -> auth
|
||||
.requestMatchers("/diag/**").permitAll()
|
||||
.anyRequest().authenticated())
|
||||
.httpBasic(Customizer.withDefaults())
|
||||
.csrf((csrf) -> csrf.disable())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
18
cors-csrf/src/main/java/com/ankurm/cors/config/Users.java
Normal file
18
cors-csrf/src/main/java/com/ankurm/cors/config/Users.java
Normal file
@@ -0,0 +1,18 @@
|
||||
package com.ankurm.cors.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
|
||||
|
||||
/** One user, {@code alice}/{@code password}, shared by every profile. */
|
||||
@Configuration
|
||||
public class Users {
|
||||
|
||||
@Bean
|
||||
UserDetailsService userDetailsService() {
|
||||
return new InMemoryUserDetailsManager(
|
||||
User.withUsername("alice").password("{noop}password").roles("USER").build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.ankurm.cors.config;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
/**
|
||||
* Profile {@code wildcard}: {@code allowedOrigins("*")} together with
|
||||
* {@code allowCredentials(true)}.
|
||||
*
|
||||
* <p>This is the combination every "just make CORS work" answer suggests, and it is illegal
|
||||
* under the Fetch standard: a response may not carry both
|
||||
* {@code Access-Control-Allow-Origin: *} and {@code Access-Control-Allow-Credentials: true}.
|
||||
* Spring does not reject it at startup. It rejects it on the first preflight, from inside
|
||||
* {@code CorsConfiguration.checkOrigin}, which means the failure surfaces as a 500 on an
|
||||
* {@code OPTIONS} request rather than as a configuration error.
|
||||
*
|
||||
* <p>The fix is {@code setAllowedOriginPatterns(..)}, which echoes the request origin back
|
||||
* instead of a literal asterisk.
|
||||
*/
|
||||
@Configuration
|
||||
@Profile("wildcard")
|
||||
public class WildcardCredentialsConfig {
|
||||
|
||||
@Bean
|
||||
UrlBasedCorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOrigins(List.of("*"));
|
||||
configuration.setAllowedMethods(List.of("GET", "POST"));
|
||||
configuration.setAllowCredentials(true);
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/api/**", configuration);
|
||||
return source;
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Order(1)
|
||||
SecurityFilterChain chain(HttpSecurity http) throws Exception {
|
||||
return http
|
||||
.authorizeHttpRequests((auth) -> auth
|
||||
.requestMatchers("/diag/**").permitAll()
|
||||
.anyRequest().authenticated())
|
||||
.httpBasic(Customizer.withDefaults())
|
||||
.csrf((csrf) -> csrf.disable())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.ankurm.cors.spec;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Runs the {@code Set-Cookie} headers this application actually emits through
|
||||
* {@link SpecCookieJar} and reports what a browser would do with them.
|
||||
*
|
||||
* <p>Pass real headers with repeated {@code ?h=} parameters —
|
||||
* {@code scripts/scenario-samesite.sh} collects them from a live response and feeds them back
|
||||
* in, so the input is never typed by hand.
|
||||
*/
|
||||
@RestController
|
||||
public class CookieSpecReport {
|
||||
|
||||
@GetMapping("/diag/cookie-spec")
|
||||
public Map<String, Object> report(@RequestParam("h") List<String> headers,
|
||||
@RequestParam(name = "secure", defaultValue = "false") boolean secureContext) {
|
||||
SpecCookieJar jar = new SpecCookieJar();
|
||||
Map<String, String> outcomes = new LinkedHashMap<>();
|
||||
for (String header : headers) {
|
||||
String rejection = jar.setCookie(header, secureContext);
|
||||
outcomes.put(header, (rejection == null) ? "stored" : rejection);
|
||||
}
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("origin", secureContext ? "trustworthy (https, or http://localhost)" : "not trustworthy (plain http)");
|
||||
out.put("setCookieOutcomes", outcomes);
|
||||
out.put("sentOnSameSiteRequest",
|
||||
orNone(jar.cookieHeaderFor(SpecCookieJar.Context.SAME_SITE, true)));
|
||||
out.put("sentOnCrossSiteTopLevelNavigation",
|
||||
orNone(jar.cookieHeaderFor(SpecCookieJar.Context.CROSS_SITE_TOP_LEVEL_NAVIGATION, true)));
|
||||
out.put("sentOnCrossSiteFetch",
|
||||
orNone(jar.cookieHeaderFor(SpecCookieJar.Context.CROSS_SITE_SUBRESOURCE, false)));
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String orNone(String header) {
|
||||
return header.isEmpty() ? "(no cookies sent)" : header;
|
||||
}
|
||||
}
|
||||
144
cors-csrf/src/main/java/com/ankurm/cors/spec/SpecCookieJar.java
Normal file
144
cors-csrf/src/main/java/com/ankurm/cors/spec/SpecCookieJar.java
Normal file
@@ -0,0 +1,144 @@
|
||||
package com.ankurm.cors.spec;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* A deliberately small cookie jar that applies the storage and sending rules a browser applies,
|
||||
* so that "the browser drops this cookie" becomes something you can run instead of something
|
||||
* you have to believe.
|
||||
*
|
||||
* <p>It is a model of two paragraphs of
|
||||
* <a href="https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis">RFC 6265bis</a>,
|
||||
* not a browser:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>§5.5 storage.</b> "If the cookie's {@code same-site-flag} is {@code None} and the
|
||||
* cookie's {@code secure-only-flag} is false, then abort these steps and ignore the newly
|
||||
* created cookie entirely." A {@code Set-Cookie} with {@code SameSite=None} and no
|
||||
* {@code Secure} is not stored, and there is no error anywhere — the cookie simply never
|
||||
* exists.</li>
|
||||
* <li><b>§5.8.3 sending.</b> A cookie whose {@code same-site-flag} is {@code Strict} or
|
||||
* {@code Lax} is not attached to a cross-site request; {@code Lax} makes an exception for
|
||||
* top-level safe-method navigations, which a {@code fetch()} from a SPA is not. A cookie with
|
||||
* no {@code SameSite} attribute is treated as {@code Lax} — by Chromium-based browsers.
|
||||
* Firefox has not enabled Lax-by-default on its release channel, so it still treats an absent
|
||||
* attribute as unrestricted. This jar models the Chromium behaviour, because that is the one
|
||||
* a deployment has to survive.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Feeding the real {@code Set-Cookie} headers the application emits through this jar is what
|
||||
* turns the SameSite section of the article into evidence. See
|
||||
* <a href="../../../../docs/07-samesite.md">docs/07-samesite.md</a>.
|
||||
*/
|
||||
public final class SpecCookieJar {
|
||||
|
||||
/** How the request was initiated, which is what decides the same-site check. */
|
||||
public enum Context {
|
||||
/** Same registrable domain as the cookie's origin. */
|
||||
SAME_SITE,
|
||||
/** A top-level navigation (clicking a link, a form GET) from another site. */
|
||||
CROSS_SITE_TOP_LEVEL_NAVIGATION,
|
||||
/** An XHR/fetch/subresource load from another site. This is the SPA case. */
|
||||
CROSS_SITE_SUBRESOURCE
|
||||
}
|
||||
|
||||
/** A stored cookie, after the attributes have been parsed. */
|
||||
public record StoredCookie(String name, String value, String sameSite, boolean secure,
|
||||
boolean httpOnly, boolean partitioned) {
|
||||
}
|
||||
|
||||
private final Map<String, StoredCookie> jar = new LinkedHashMap<>();
|
||||
|
||||
private final List<String> rejections = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Apply one {@code Set-Cookie} header. Returns the reason it was rejected, or {@code null}
|
||||
* when it was stored.
|
||||
*/
|
||||
public String setCookie(String header, boolean secureContext) {
|
||||
String[] parts = header.split(";");
|
||||
String[] nv = parts[0].split("=", 2);
|
||||
String name = nv[0].trim();
|
||||
String value = nv.length > 1 ? nv[1].trim() : "";
|
||||
|
||||
String sameSite = null;
|
||||
boolean secure = false;
|
||||
boolean httpOnly = false;
|
||||
boolean partitioned = false;
|
||||
for (int i = 1; i < parts.length; i++) {
|
||||
String attribute = parts[i].trim();
|
||||
String lower = attribute.toLowerCase(Locale.ROOT);
|
||||
if (lower.startsWith("samesite=")) {
|
||||
sameSite = attribute.substring("samesite=".length()).trim();
|
||||
}
|
||||
else if (lower.equals("secure")) {
|
||||
secure = true;
|
||||
}
|
||||
else if (lower.equals("httponly")) {
|
||||
httpOnly = true;
|
||||
}
|
||||
else if (lower.equals("partitioned")) {
|
||||
partitioned = true;
|
||||
}
|
||||
}
|
||||
|
||||
// RFC 6265bis 5.5: the Secure attribute is only honoured from a trustworthy origin.
|
||||
// HTTPS qualifies; so does http://localhost in every current browser, which is why
|
||||
// this parameter is called secureContext rather than https.
|
||||
boolean secureHonoured = secure && secureContext;
|
||||
// RFC 6265bis 5.5: SameSite=None without an effective Secure is ignored entirely.
|
||||
if ("None".equalsIgnoreCase(sameSite) && !secureHonoured) {
|
||||
String reason = "REJECTED " + name + ": SameSite=None " + (secure
|
||||
? "with Secure, but the origin is not trustworthy so Secure is not honoured"
|
||||
: "and no Secure attribute") + " - RFC 6265bis 5.5";
|
||||
this.rejections.add(reason);
|
||||
return reason;
|
||||
}
|
||||
secure = secureHonoured;
|
||||
// Partitioned (CHIPS) requires Secure as well.
|
||||
if (partitioned && !secure) {
|
||||
String reason = "REJECTED " + name + ": Partitioned without Secure";
|
||||
this.rejections.add(reason);
|
||||
return reason;
|
||||
}
|
||||
this.jar.put(name, new StoredCookie(name, value, sameSite, secure, httpOnly, partitioned));
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The {@code Cookie} header a browser would send for a request made in this context. */
|
||||
public String cookieHeaderFor(Context context, boolean safeMethod) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (StoredCookie cookie : this.jar.values()) {
|
||||
if (!willSend(cookie, context, safeMethod)) {
|
||||
continue;
|
||||
}
|
||||
sb.append(sb.isEmpty() ? "" : "; ").append(cookie.name()).append('=').append(cookie.value());
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static boolean willSend(StoredCookie cookie, Context context, boolean safeMethod) {
|
||||
// No SameSite attribute means Lax in Chromium-based browsers, which is where the modern
|
||||
// default bites. Firefox's release channel still treats an absent attribute as
|
||||
// unrestricted; modelling the stricter of the two is the useful choice.
|
||||
String effective = (cookie.sameSite() == null) ? "Lax" : cookie.sameSite();
|
||||
return switch (context) {
|
||||
case SAME_SITE -> true;
|
||||
case CROSS_SITE_TOP_LEVEL_NAVIGATION ->
|
||||
effective.equalsIgnoreCase("None") || (effective.equalsIgnoreCase("Lax") && safeMethod);
|
||||
case CROSS_SITE_SUBRESOURCE -> effective.equalsIgnoreCase("None");
|
||||
};
|
||||
}
|
||||
|
||||
public List<String> rejections() {
|
||||
return List.copyOf(this.rejections);
|
||||
}
|
||||
|
||||
public Map<String, StoredCookie> stored() {
|
||||
return Map.copyOf(this.jar);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.ankurm.cors.web;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* The API the imaginary single-page application talks to.
|
||||
*
|
||||
* <p>{@code /api/boom} exists to demonstrate one specific failure: a request that passed the
|
||||
* CORS check and then threw. Tomcat re-dispatches to {@code /error}, and what the browser
|
||||
* reports is not the 500 — see
|
||||
* <a href="../../../../docs/05-the-error-dispatch.md">docs/05-the-error-dispatch.md</a>.
|
||||
*/
|
||||
@RestController
|
||||
public class ApiController {
|
||||
|
||||
@GetMapping("/api/data")
|
||||
public Map<String, Object> data(HttpServletRequest request) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("method", request.getMethod());
|
||||
body.put("origin", String.valueOf(request.getHeader("Origin")));
|
||||
body.put("cookies", cookieNames(request));
|
||||
return body;
|
||||
}
|
||||
|
||||
@PostMapping("/api/data")
|
||||
public Map<String, Object> create(HttpServletRequest request,
|
||||
@RequestBody(required = false) String body) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("created", true);
|
||||
out.put("received", body == null ? "" : body);
|
||||
out.put("cookies", cookieNames(request));
|
||||
return out;
|
||||
}
|
||||
|
||||
@GetMapping("/api/whoami")
|
||||
public Map<String, Object> whoami(Authentication authentication, HttpServletRequest request) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("name", authentication == null ? "(none)" : authentication.getName());
|
||||
out.put("sessionId", request.getSession(false) == null ? "(no session)" : "present");
|
||||
return out;
|
||||
}
|
||||
|
||||
@GetMapping("/api/boom")
|
||||
public Map<String, Object> boom() {
|
||||
throw new IllegalStateException("deliberate failure, so you can watch the CORS headers vanish");
|
||||
}
|
||||
|
||||
private static String cookieNames(HttpServletRequest request) {
|
||||
if (request.getCookies() == null) {
|
||||
return "(none)";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (var c : request.getCookies()) {
|
||||
sb.append(sb.isEmpty() ? "" : ",").append(c.getName());
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.ankurm.cors.web;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import jakarta.servlet.Filter;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.security.web.FilterChainProxy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
|
||||
/**
|
||||
* Prints runtime state that is otherwise invisible: which filters are actually in the chain,
|
||||
* and which {@code CorsConfigurationSource} beans the context holds and what they are named.
|
||||
*
|
||||
* <p>The bean-name question matters more than it looks. {@code CorsConfigurer} resolves the
|
||||
* source by the bean <em>name</em> {@code corsConfigurationSource}, while the code that decides
|
||||
* whether to switch CORS on at all looks it up by <em>type</em>. See
|
||||
* <a href="../../../../docs/02-who-resolves-the-source.md">docs/02-who-resolves-the-source.md</a>.
|
||||
*
|
||||
* <p>Delete this controller before shipping anything.
|
||||
*/
|
||||
@RestController
|
||||
public class DiagController {
|
||||
|
||||
private final FilterChainProxy proxy;
|
||||
|
||||
private final Map<String, CorsConfigurationSource> sources;
|
||||
|
||||
private final Environment environment;
|
||||
|
||||
public DiagController(@Qualifier("springSecurityFilterChain") Filter springSecurityFilterChain,
|
||||
Map<String, CorsConfigurationSource> sources, Environment environment) {
|
||||
this.proxy = (FilterChainProxy) springSecurityFilterChain;
|
||||
this.sources = sources;
|
||||
this.environment = environment;
|
||||
}
|
||||
|
||||
@GetMapping("/diag/chain")
|
||||
public Map<String, Object> chain() {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("profiles", List.of(this.environment.getActiveProfiles()));
|
||||
List<Map<String, Object>> chains = new ArrayList<>();
|
||||
for (SecurityFilterChain chain : this.proxy.getFilterChains()) {
|
||||
Map<String, Object> one = new LinkedHashMap<>();
|
||||
one.put("size", chain.getFilters().size());
|
||||
List<String> names = new ArrayList<>();
|
||||
for (Filter filter : chain.getFilters()) {
|
||||
names.add(filter.getClass().getSimpleName());
|
||||
}
|
||||
one.put("filters", names);
|
||||
chains.add(one);
|
||||
}
|
||||
out.put("chains", chains);
|
||||
return out;
|
||||
}
|
||||
|
||||
@GetMapping("/diag/cors-sources")
|
||||
public Map<String, Object> corsSources() {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
Map<String, String> byName = new LinkedHashMap<>();
|
||||
this.sources.forEach((name, source) -> byName.put(name, source.getClass().getSimpleName()));
|
||||
out.put("corsConfigurationSourceBeans", byName);
|
||||
out.put("hasBeanNamedCorsConfigurationSource", this.sources.containsKey("corsConfigurationSource"));
|
||||
return out;
|
||||
}
|
||||
}
|
||||
25
cors-csrf/src/main/resources/application.yml
Normal file
25
cors-csrf/src/main/resources/application.yml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Every scenario in the article is a profile. The default is `securitysource`, the
|
||||
# configuration that works, so that a bare `mvn spring-boot:run` starts something sane.
|
||||
spring:
|
||||
application:
|
||||
name: cors-csrf-samesite
|
||||
profiles:
|
||||
default: securitysource
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
servlet:
|
||||
session:
|
||||
cookie:
|
||||
# Boot writes exactly what you put here. It does NOT add `Secure` for you when
|
||||
# same-site is `none`, which is the whole subject of docs/07-samesite.md. Flip
|
||||
# SESSION_SAME_SITE / SESSION_SECURE from scripts/scenario-samesite.sh and read the
|
||||
# emitted Set-Cookie header back.
|
||||
same-site: ${SESSION_SAME_SITE:lax}
|
||||
secure: ${SESSION_SECURE:false}
|
||||
http-only: true
|
||||
|
||||
logging:
|
||||
level:
|
||||
org.springframework.security.web.csrf: ${CSRF_LOG_LEVEL:INFO}
|
||||
org.springframework.web.cors: ${CORS_LOG_LEVEL:INFO}
|
||||
221
cors-csrf/src/test/java/com/ankurm/cors/CorsContractTests.java
Normal file
221
cors-csrf/src/test/java/com/ankurm/cors/CorsContractTests.java
Normal file
@@ -0,0 +1,221 @@
|
||||
package com.ankurm.cors;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.assertj.MockMvcTester;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.context.WebApplicationContext;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Assertions that pin the <em>contract</em>: which status code a browser sees, and whether the
|
||||
* response carries the header that decides whether the browser will show it. They are written
|
||||
* against the same profiles the transcripts in {@code docs/output/} use, so a change in Spring
|
||||
* Security that alters any of this breaks a test rather than a paragraph.
|
||||
*
|
||||
* <p>These run through {@code MockMvc} with {@code springSecurityFilterChain} applied. That
|
||||
* exercises the filter chain, which is the layer under test; it does <em>not</em> exercise the
|
||||
* container's error dispatch, which is why the {@code /error} finding is verified by the
|
||||
* transcripts in {@code docs/output/09-error-dispatch.txt} rather than here. Noted rather than
|
||||
* hidden: it is a real limit of this test setup.
|
||||
*/
|
||||
class CorsContractTests {
|
||||
|
||||
private static MockMvcTester tester(WebApplicationContext context) {
|
||||
MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(context)
|
||||
.apply(org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers
|
||||
.springSecurity())
|
||||
.build();
|
||||
return MockMvcTester.create(mockMvc);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("mvconly")
|
||||
@DisplayName("CORS on the MVC layer only")
|
||||
class MvcOnly {
|
||||
|
||||
@Autowired
|
||||
WebApplicationContext context;
|
||||
|
||||
@Test
|
||||
@DisplayName("the preflight is rejected by authorization, and carries no CORS header")
|
||||
void preflightIsRejected() {
|
||||
assertThat(tester(this.context).options().uri("/api/data")
|
||||
.header(HttpHeaders.ORIGIN, "https://spa.example.com")
|
||||
.header("Access-Control-Request-Method", "POST"))
|
||||
.hasStatus(401)
|
||||
.doesNotContainHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no CorsFilter is in the chain")
|
||||
void noCorsFilter() {
|
||||
assertThat(chainClassNames(this.context)).doesNotContain("CorsFilter");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("mvcbridge")
|
||||
@DisplayName("the same MVC configuration plus .cors(withDefaults())")
|
||||
class MvcBridge {
|
||||
|
||||
@Autowired
|
||||
WebApplicationContext context;
|
||||
|
||||
@Test
|
||||
@DisplayName("the preflight is answered by CorsFilter with the MVC configuration")
|
||||
void preflightSucceeds() {
|
||||
assertThat(tester(this.context).options().uri("/api/data")
|
||||
.header(HttpHeaders.ORIGIN, "https://spa.example.com")
|
||||
.header("Access-Control-Request-Method", "POST"))
|
||||
.hasStatus(200)
|
||||
.hasHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "https://spa.example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("MVC's CorsRegistration supplies a max-age default that CorsConfiguration does not")
|
||||
void mvcSuppliesMaxAge() {
|
||||
assertThat(tester(this.context).options().uri("/api/data")
|
||||
.header(HttpHeaders.ORIGIN, "https://spa.example.com")
|
||||
.header("Access-Control-Request-Method", "POST"))
|
||||
.hasHeader(HttpHeaders.ACCESS_CONTROL_MAX_AGE, "1800");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("CorsFilter sits between HeaderWriterFilter and LogoutFilter")
|
||||
void corsFilterPosition() {
|
||||
var names = chainClassNames(this.context);
|
||||
assertThat(names).contains("CorsFilter");
|
||||
assertThat(names.indexOf("CorsFilter")).isGreaterThan(names.indexOf("HeaderWriterFilter"));
|
||||
assertThat(names.indexOf("CorsFilter")).isLessThan(names.indexOf("AuthorizationFilter"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("securitysource")
|
||||
@DisplayName("a bean named corsConfigurationSource")
|
||||
class SecuritySource {
|
||||
|
||||
@Autowired
|
||||
WebApplicationContext context;
|
||||
|
||||
@Test
|
||||
@DisplayName("no max-age is emitted, so every request re-runs the preflight")
|
||||
void noMaxAge() {
|
||||
assertThat(tester(this.context).options().uri("/api/data")
|
||||
.header(HttpHeaders.ORIGIN, "https://spa.example.com")
|
||||
.header("Access-Control-Request-Method", "POST"))
|
||||
.hasStatus(200)
|
||||
.doesNotContainHeader(HttpHeaders.ACCESS_CONTROL_MAX_AGE);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("origin, method and header rejections are indistinguishable to the client")
|
||||
void threeRejectionsLookIdentical() throws Exception {
|
||||
var badOrigin = tester(this.context).options().uri("/api/data")
|
||||
.header(HttpHeaders.ORIGIN, "https://evil.example.com")
|
||||
.header("Access-Control-Request-Method", "POST").exchange();
|
||||
var badMethod = tester(this.context).options().uri("/api/data")
|
||||
.header(HttpHeaders.ORIGIN, "https://spa.example.com")
|
||||
.header("Access-Control-Request-Method", "DELETE").exchange();
|
||||
var badHeader = tester(this.context).options().uri("/api/data")
|
||||
.header(HttpHeaders.ORIGIN, "https://spa.example.com")
|
||||
.header("Access-Control-Request-Method", "POST")
|
||||
.header("Access-Control-Request-Headers", "authorization").exchange();
|
||||
|
||||
assertThat(badOrigin.getResponse().getStatus()).isEqualTo(403);
|
||||
assertThat(badMethod.getResponse().getStatus()).isEqualTo(403);
|
||||
assertThat(badHeader.getResponse().getStatus()).isEqualTo(403);
|
||||
assertThat(badOrigin.getResponse().getContentAsString()).isEqualTo("Invalid CORS request");
|
||||
assertThat(badMethod.getResponse().getContentAsString())
|
||||
.isEqualTo(badOrigin.getResponse().getContentAsString());
|
||||
assertThat(badHeader.getResponse().getContentAsString())
|
||||
.isEqualTo(badOrigin.getResponse().getContentAsString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an unauthenticated request still carries the CORS header, so the SPA can read the 401")
|
||||
void unauthenticatedStillCarriesCorsHeader() {
|
||||
assertThat(tester(this.context).get().uri("/api/data")
|
||||
.header(HttpHeaders.ORIGIN, "https://spa.example.com"))
|
||||
.hasStatus(401)
|
||||
.hasHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "https://spa.example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a path outside the registered pattern gets no CORS header at all")
|
||||
void outsideThePatternGetsNothing() {
|
||||
assertThat(tester(this.context).get().uri("/nope")
|
||||
.header(HttpHeaders.ORIGIN, "https://spa.example.com"))
|
||||
.doesNotContainHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("misnamed")
|
||||
@DisplayName("the right type under the wrong bean name")
|
||||
class Misnamed {
|
||||
|
||||
@Autowired
|
||||
WebApplicationContext context;
|
||||
|
||||
@Test
|
||||
@DisplayName("the context starts and CorsFilter is in the chain")
|
||||
void itStarts() {
|
||||
assertThat(chainClassNames(this.context)).contains("CorsFilter");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the preflight returns 200 with no CORS headers - the most confusing state there is")
|
||||
void twoHundredWithNothing() {
|
||||
assertThat(tester(this.context).options().uri("/api/data")
|
||||
.header(HttpHeaders.ORIGIN, "https://spa.example.com")
|
||||
.header("Access-Control-Request-Method", "POST"))
|
||||
.hasStatus(200)
|
||||
.doesNotContainHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("twosources")
|
||||
@DisplayName("two UrlBasedCorsConfigurationSource beans")
|
||||
class TwoSources {
|
||||
|
||||
@Autowired
|
||||
WebApplicationContext context;
|
||||
|
||||
@Test
|
||||
@DisplayName("CORS is configured anyway, and the bean NAME decides which one wins")
|
||||
void nameWins() {
|
||||
assertThat(tester(this.context).options().uri("/api/data")
|
||||
.header(HttpHeaders.ORIGIN, "https://spa.example.com")
|
||||
.header("Access-Control-Request-Method", "POST"))
|
||||
.hasStatus(200);
|
||||
assertThat(tester(this.context).options().uri("/api/data")
|
||||
.header(HttpHeaders.ORIGIN, "https://admin.example.com")
|
||||
.header("Access-Control-Request-Method", "POST"))
|
||||
.hasStatus(403);
|
||||
}
|
||||
}
|
||||
|
||||
private static java.util.List<String> chainClassNames(WebApplicationContext context) {
|
||||
var proxy = (org.springframework.security.web.FilterChainProxy) context
|
||||
.getBean("springSecurityFilterChain");
|
||||
return proxy.getFilterChains().get(proxy.getFilterChains().size() - 1).getFilters().stream()
|
||||
.map((filter) -> filter.getClass().getSimpleName())
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
193
cors-csrf/src/test/java/com/ankurm/cors/CsrfAndCookieTests.java
Normal file
193
cors-csrf/src/test/java/com/ankurm/cors/CsrfAndCookieTests.java
Normal file
@@ -0,0 +1,193 @@
|
||||
package com.ankurm.cors;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.ankurm.cors.spec.SpecCookieJar;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.assertj.MockMvcTester;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.context.WebApplicationContext;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/** CSRF-for-SPAs behaviour, and the cookie rules that decide whether the token ever arrives. */
|
||||
class CsrfAndCookieTests {
|
||||
|
||||
private static MockMvcTester tester(WebApplicationContext context) {
|
||||
MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(context)
|
||||
.apply(org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers
|
||||
.springSecurity())
|
||||
.build();
|
||||
return MockMvcTester.create(mockMvc);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("csrfnaive")
|
||||
@DisplayName("CookieCsrfTokenRepository.withHttpOnlyFalse() on its own")
|
||||
class Naive {
|
||||
|
||||
@Autowired
|
||||
WebApplicationContext context;
|
||||
|
||||
@Test
|
||||
@DisplayName("the bootstrap GET sets no cookie, because the token is deferred")
|
||||
void bootstrapGetSetsNoCookie() {
|
||||
var result = tester(this.context).get().uri("/api/data")
|
||||
.with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
|
||||
.user("alice"))
|
||||
.exchange();
|
||||
assertThat(result.getResponse().getCookie("XSRF-TOKEN")).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("csrfspa")
|
||||
@DisplayName("csrf.spa()")
|
||||
class Spa {
|
||||
|
||||
@Autowired
|
||||
WebApplicationContext context;
|
||||
|
||||
@Test
|
||||
@DisplayName("the bootstrap GET does set the cookie, because spa() resolves the token eagerly")
|
||||
void bootstrapGetSetsCookie() {
|
||||
var result = tester(this.context).get().uri("/api/data")
|
||||
.with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
|
||||
.user("alice"))
|
||||
.exchange();
|
||||
var cookie = result.getResponse().getCookie("XSRF-TOKEN");
|
||||
assertThat(cookie).isNotNull();
|
||||
// The mechanism: spa() installs a handler whose XOR delegate has a null
|
||||
// csrfRequestAttributeName, so CsrfTokenRequestAttributeHandler.handle falls back
|
||||
// to token.getParameterName() for the attribute key - and calling that method on
|
||||
// the SupplierCsrfToken is what dereferences the deferred token.
|
||||
assertThat(cookie.getValue()).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the cookie carries no SameSite and no Secure attribute")
|
||||
void cookieHasNoSameSite() {
|
||||
var result = tester(this.context).get().uri("/api/data")
|
||||
.with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
|
||||
.user("alice"))
|
||||
.exchange();
|
||||
var cookie = result.getResponse().getCookie("XSRF-TOKEN");
|
||||
assertThat(cookie).isNotNull();
|
||||
assertThat(cookie.getSecure()).isFalse();
|
||||
assertThat(cookie.getAttribute("SameSite")).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the raw cookie value works in the header, which is the whole point of spa()")
|
||||
void rawCookieValueIsAccepted() {
|
||||
var tester = tester(this.context);
|
||||
var bootstrap = tester.get().uri("/api/data")
|
||||
.with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
|
||||
.user("alice"))
|
||||
.exchange();
|
||||
var cookie = bootstrap.getResponse().getCookie("XSRF-TOKEN");
|
||||
assertThat(tester.post().uri("/api/data")
|
||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||
.content("{}")
|
||||
.cookie(cookie)
|
||||
.header("X-XSRF-TOKEN", cookie.getValue())
|
||||
.with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
|
||||
.user("alice")))
|
||||
.hasStatus(200);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("spaorder")
|
||||
@DisplayName("csrfTokenRepository(..) before spa()")
|
||||
class Ordering {
|
||||
|
||||
@Autowired
|
||||
WebApplicationContext context;
|
||||
|
||||
@Test
|
||||
@DisplayName("the custom repository is discarded and the default cookie name comes back")
|
||||
void customRepositoryIsDiscarded() {
|
||||
var result = tester(this.context).get().uri("/api/data")
|
||||
.with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
|
||||
.user("alice"))
|
||||
.exchange();
|
||||
assertThat(result.getResponse().getCookie("MY-CSRF")).isNull();
|
||||
assertThat(result.getResponse().getCookie("XSRF-TOKEN")).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("SpecCookieJar - the storage and sending rules a browser applies")
|
||||
class Spec {
|
||||
|
||||
@Test
|
||||
@DisplayName("SameSite=None without Secure is ignored entirely")
|
||||
void noneWithoutSecureIsDropped() {
|
||||
SpecCookieJar jar = new SpecCookieJar();
|
||||
assertThat(jar.setCookie("XSRF-TOKEN=t; Path=/; SameSite=None", true))
|
||||
.contains("RFC 6265bis");
|
||||
assertThat(jar.stored()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Secure is not honoured from an untrustworthy origin, so None+Secure is dropped over plain http")
|
||||
void secureNeedsATrustworthyOrigin() {
|
||||
SpecCookieJar jar = new SpecCookieJar();
|
||||
assertThat(jar.setCookie("XSRF-TOKEN=t; Path=/; Secure; SameSite=None", false)).isNotNull();
|
||||
assertThat(jar.stored()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a cookie with no SameSite attribute behaves as Lax and is not sent on a cross-site fetch")
|
||||
void absentSameSiteIsLax() {
|
||||
SpecCookieJar jar = new SpecCookieJar();
|
||||
assertThat(jar.setCookie("XSRF-TOKEN=t; Path=/", true)).isNull();
|
||||
assertThat(jar.cookieHeaderFor(SpecCookieJar.Context.SAME_SITE, true)).isEqualTo("XSRF-TOKEN=t");
|
||||
assertThat(jar.cookieHeaderFor(SpecCookieJar.Context.CROSS_SITE_SUBRESOURCE, false)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Lax is sent on a cross-site top-level navigation but not on a cross-site fetch")
|
||||
void laxNavigationException() {
|
||||
SpecCookieJar jar = new SpecCookieJar();
|
||||
jar.setCookie("JSESSIONID=s; Path=/; SameSite=Lax", true);
|
||||
assertThat(jar.cookieHeaderFor(SpecCookieJar.Context.CROSS_SITE_TOP_LEVEL_NAVIGATION, true))
|
||||
.isEqualTo("JSESSIONID=s");
|
||||
assertThat(jar.cookieHeaderFor(SpecCookieJar.Context.CROSS_SITE_SUBRESOURCE, false)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("only Secure + SameSite=None survives to a cross-site fetch")
|
||||
void onlyNoneSecureSurvives() {
|
||||
SpecCookieJar jar = new SpecCookieJar();
|
||||
for (String header : List.of("a=1; Path=/", "b=2; Path=/; SameSite=Lax",
|
||||
"c=3; Path=/; SameSite=Strict", "d=4; Path=/; Secure; SameSite=None")) {
|
||||
jar.setCookie(header, true);
|
||||
}
|
||||
assertThat(jar.cookieHeaderFor(SpecCookieJar.Context.CROSS_SITE_SUBRESOURCE, false))
|
||||
.isEqualTo("d=4");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Partitioned without Secure is rejected too")
|
||||
void partitionedNeedsSecure() {
|
||||
SpecCookieJar jar = new SpecCookieJar();
|
||||
assertThat(jar.setCookie("x=1; Path=/; Partitioned", true)).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static final HttpHeaders UNUSED = null;
|
||||
}
|
||||
Reference in New Issue
Block a user