Compare commits
3 Commits
3170adef18
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e37a54e92 | |||
| 0fceb2cd4e | |||
| cad813e1ae |
51
README.md
51
README.md
@@ -10,37 +10,62 @@ 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 |
|
||||
| [`service-to-service/`](service-to-service/README.md) | [Securing Spring Boot Microservices: Token Relay, Service-to-Service JWT and mTLS](https://ankurm.com/spring-boot-microservices-token-relay-mtls/) | Whose identity arrives at the last service under relay, client credentials and token exchange — and what a resource server does not check by default |
|
||||
| [`ssrf/`](ssrf/README.md) | [HTTP Client SSRF Mitigation in Spring Boot 4.1](https://ankurm.com/spring-boot-4-1-ssrf-inetaddressfilter/) | A working SSRF exploit against a link-preview endpoint, and the `InetAddressFilter` that stops it — including the two ways of configuring it that silently do the opposite |
|
||||
|
||||
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.
|
||||
|
||||
`ssrf` is the outbound counterpart to all of them. Every other module asks what a request
|
||||
arriving at this application is allowed to do; this one asks where this application is allowed
|
||||
to send a request, which turns out to be the question an attacker cares about once they have
|
||||
found an endpoint that fetches a URL. Its filter is not part of Spring Security at all — it
|
||||
is a Boot 4.1 HTTP-client control — and that is worth noticing, because a `SecurityFilterChain`
|
||||
has nothing to say about it.
|
||||
|
||||
`service-to-service` is the same question one process further out: `cors-csrf` and
|
||||
`context-propagation` ask whether an identity survives a thread or a browser boundary, and this
|
||||
one asks whether it survives an HTTP boundary — and what the service on the far side bothers to
|
||||
verify about it. Its `/edge/relay-async` endpoint fails for exactly the reason
|
||||
`context-propagation` documents.
|
||||
|
||||
`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`, `cors-csrf` and
|
||||
`service-to-service` 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.
|
||||
`service-to-service` runs five of them at once, and is the only module that also pulls in
|
||||
Spring Cloud — a separate release train, built against Boot 4.0.8 rather than 4.1.1.
|
||||
|
||||
## Running a module
|
||||
|
||||
```bash
|
||||
cd method-security # or context-propagation, or filter-chain
|
||||
cd cors-csrf # or context-propagation, method-security, filter-chain, service-to-service
|
||||
./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;
|
||||
}
|
||||
103
service-to-service/README.md
Normal file
103
service-to-service/README.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# `service-to-service` — token relay, client credentials, exchange and mTLS
|
||||
|
||||
Companion project for
|
||||
[**Securing Spring Boot Microservices: Token Relay, Service-to-Service JWT and mTLS**](https://ankurm.com/spring-boot-microservices-token-relay-mtls/)
|
||||
on ankurm.com.
|
||||
|
||||
Four real processes and a real Spring Authorization Server, so that questions like "whose
|
||||
identity arrives at the last service?" and "what does that service actually check?" have
|
||||
transcripts for answers. Everything under [`docs/output/`](docs/output/) was produced by
|
||||
`scripts/run-all.sh`.
|
||||
|
||||
## Versions
|
||||
|
||||
| | Version | Notes |
|
||||
|---|---|---|
|
||||
| JDK | 25 (Temurin 25.0.4.1+1) | current LTS |
|
||||
| Spring Boot | 4.1.1 | inherited as parent |
|
||||
| Spring Framework | 7.0.9 | |
|
||||
| Spring Security | 7.1.1 | resource server, OAuth2 client, authorization server |
|
||||
| Spring Cloud | 2025.1.3 (gateway 5.0.3) | **built against Boot 4.0.8** — see [docs/01](docs/01-the-four-processes.md) |
|
||||
| Tomcat | 11.0.24 | |
|
||||
|
||||
Versions were read from `repo1.maven.org/.../maven-metadata.xml`.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
./scripts/run.sh # all four processes, in order
|
||||
TOKEN=$(./scripts/user-token.sh) # a real authorization_code + PKCE flow, in curl
|
||||
./scripts/claims.sh "$TOKEN"
|
||||
|
||||
curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8081/edge/relay
|
||||
curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8081/edge/client-credentials
|
||||
curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8081/edge/exchange
|
||||
|
||||
STRICT=true ./scripts/run.sh # issuer + audience + RFC 9068 validation
|
||||
./scripts/certs.sh && ./scripts/run-all.sh
|
||||
mvn test # the 8 validator assertions
|
||||
./scripts/stop.sh
|
||||
```
|
||||
|
||||
The user is `alice` / `password`.
|
||||
|
||||
## Processes
|
||||
|
||||
| Process | Port | Main class | Role |
|
||||
|---|---|---|---|
|
||||
| authserver | 9000 | [`AuthServerApplication`](src/main/java/com/ankurm/s2s/authserver/AuthServerApplication.java) | Spring Authorization Server. Mints every token used here |
|
||||
| gateway | 8080 | [`GatewayApplication`](src/main/java/com/ankurm/s2s/gateway/GatewayApplication.java) | Spring Cloud Gateway Server MVC, with and without `TokenRelay` |
|
||||
| edge | 8081 | [`EdgeApplication`](src/main/java/com/ankurm/s2s/edge/EdgeApplication.java) | Resource server **and** OAuth2 client |
|
||||
| downstream | 8082 | [`DownstreamApplication`](src/main/java/com/ankurm/s2s/downstream/DownstreamApplication.java) | Resource server. Reports who it thinks is calling |
|
||||
| mtls | 8443 | [`MtlsApplication`](src/main/java/com/ankurm/s2s/mtls/MtlsApplication.java) | Certificate authentication instead of tokens |
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Endpoint | Strategy |
|
||||
|---|---|
|
||||
| `GET /edge/naive` | No token forwarded — the control |
|
||||
| `GET /edge/relay` | The incoming bearer token, unchanged |
|
||||
| `GET /edge/client-credentials` | The edge service's own identity |
|
||||
| `GET /edge/exchange` | RFC 8693 token exchange |
|
||||
| `GET /edge/relay-async` | Relay from another thread — the `ThreadLocal` trap |
|
||||
| `GET /orders` | Downstream. Echoes `sub`, `aud`, `scope`, `client_id`, `cnf` |
|
||||
| `GET /mtls/whoami` | The verified certificate identity |
|
||||
| `GET /mtls/trusted-header` | An identity taken from a header, verified by nothing |
|
||||
|
||||
## Switches
|
||||
|
||||
| Switch | Effect |
|
||||
|---|---|
|
||||
| `STRICT=true ./scripts/run.sh` | Authorization server emits RFC 9068 tokens; downstream validates issuer, audience and the required-claim set |
|
||||
| `RS_LOG_LEVEL` / `CLIENT_LOG_LEVEL` / `AS_LOG_LEVEL` / `GATEWAY_LOG_LEVEL` | `DEBUG` on the corresponding package |
|
||||
|
||||
## Documentation
|
||||
|
||||
| Chapter | |
|
||||
|---|---|
|
||||
| [01](docs/01-the-four-processes.md) | The four processes, a browser flow in curl, and four things that cost time |
|
||||
| [02](docs/02-three-ways-to-get-a-token.md) | Relay, client credentials, token exchange — and the thread that loses the token |
|
||||
| [03](docs/03-restclient-interceptors.md) | `OAuth2ClientHttpRequestInterceptor` and which `OAuth2AuthorizedClientManager` |
|
||||
| [04](docs/04-what-is-not-validated.md) | **What a resource server does not validate by default** |
|
||||
| [05](docs/05-the-gateway.md) | What `TokenRelay` actually relays |
|
||||
| [06](docs/06-mtls.md) | Mesh mTLS versus in-application mTLS |
|
||||
| [07](docs/07-choosing.md) | Choosing, and whether you need any of it |
|
||||
|
||||
## Captured output
|
||||
|
||||
| File | |
|
||||
|---|---|
|
||||
| [01-user-token.txt](docs/output/01-user-token.txt) | A complete authorization_code + PKCE flow, in curl |
|
||||
| [02-five-strategies.txt](docs/output/02-five-strategies.txt) | Five propagation strategies, one request |
|
||||
| [03-audience-ignored.txt](docs/output/03-audience-ignored.txt) | A token for another service, accepted with HTTP 200 |
|
||||
| [04-gateway-token-relay.txt](docs/output/04-gateway-token-relay.txt) | The same route with and without `TokenRelay` |
|
||||
| [05-strict-validation.txt](docs/output/05-strict-validation.txt) | Strict validation, and what turning it on breaks |
|
||||
| [06-mtls.txt](docs/output/06-mtls.txt) | Two certificates with the same subject and different issuers |
|
||||
| [07-tests.txt](docs/output/07-tests.txt) | `mvn test` |
|
||||
|
||||
## Related modules
|
||||
|
||||
- [`filter-chain/`](../filter-chain/README.md) — where `BearerTokenAuthenticationFilter` sits
|
||||
- [`context-propagation/`](../context-propagation/README.md) — why `/edge/relay-async` returns 401
|
||||
- [`cors-csrf/`](../cors-csrf/README.md) — the browser-facing half of the same problem
|
||||
- [`method-security/`](../method-security/README.md) — turning a scope into an authorization decision
|
||||
75
service-to-service/docs/01-the-four-processes.md
Normal file
75
service-to-service/docs/01-the-four-processes.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# 1. The four processes
|
||||
|
||||
*Next: [2. Three ways to get a token for the next hop](02-three-ways-to-get-a-token.md)*
|
||||
|
||||
Everything in this module runs against four real processes, because the questions it answers
|
||||
(“whose identity arrives?”, “what does the receiving service check?”) only have answers when
|
||||
there is a second process to arrive at.
|
||||
|
||||
| Process | Port | What it is |
|
||||
|---|---|---|
|
||||
| `authserver` | 9000 | A real Spring Authorization Server. Mints every token used anywhere here |
|
||||
| `gateway` | 8080 | Spring Cloud Gateway Server MVC, proxying to `edge` |
|
||||
| `edge` | 8081 | Resource server **and** OAuth2 client. The middle hop |
|
||||
| `downstream` | 8082 | Resource server. Reports who it thinks is calling |
|
||||
| `mtls` | 8443 | Separate. Certificate authentication instead of tokens — chapter 6 |
|
||||
|
||||
`./scripts/run.sh` starts the first four in order and waits for each.
|
||||
|
||||
## Getting a user token without a browser
|
||||
|
||||
The "browser flow" is four HTTP requests and a cookie jar, and
|
||||
[`scripts/user-token.sh`](../scripts/user-token.sh) does all of it with `curl`:
|
||||
|
||||
1. `GET /oauth2/authorize?…` unauthenticated → the request is saved, 302 to `/login`
|
||||
2. `GET /login`, scrape the `_csrf` hidden field
|
||||
3. `POST /login` with the credentials and that token → 302 back to the saved request
|
||||
4. `GET` the authorize URL again, now authenticated → 302 to the redirect URI with `?code=`
|
||||
5. `POST /oauth2/token` with the code and the PKCE `code_verifier`
|
||||
|
||||
[`docs/output/01-user-token.txt`](output/01-user-token.txt) is the token that comes out. Doing
|
||||
this once by hand is the fastest way to understand what an OIDC library is doing on your behalf.
|
||||
|
||||
## Four things that cost time while building this
|
||||
|
||||
**`issuer-uri` creates a startup-ordering dependency.** Configuring an OAuth2 *client* with
|
||||
`spring.security.oauth2.client.provider.<id>.issuer-uri` makes `ClientRegistrations` fetch
|
||||
`/.well-known/openid-configuration` during context refresh. If the authorization server is not
|
||||
up, the client will not start:
|
||||
|
||||
```
|
||||
ResourceAccessException: I/O error on GET request for
|
||||
"http://127.0.0.1:9000/.well-known/openid-configuration": Connection refused
|
||||
```
|
||||
|
||||
Nothing in that message says "start order". Naming `token-uri` and `jwk-set-uri` explicitly
|
||||
removes the coupling, which is why `edge.yml` does.
|
||||
|
||||
**`OAuth2AuthorizationServerConfiguration` no longer exists.** Every pre-7.0 Authorization
|
||||
Server tutorial opens with
|
||||
`OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http)`. That class, and the whole
|
||||
`org.springframework.security.oauth2.server.authorization.config.annotation.web.*` package tree,
|
||||
is **absent** from `spring-security-oauth2-authorization-server` 7.1.1. The configurer moved into
|
||||
`spring-security-config` at
|
||||
`org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization`,
|
||||
and there is now a DSL method:
|
||||
|
||||
```java
|
||||
http.oauth2AuthorizationServer(Customizer.withDefaults())
|
||||
```
|
||||
|
||||
It is a compile error, not a deprecation warning.
|
||||
|
||||
**A public client is asked for consent.** `ClientAuthenticationMethod.NONE` plus
|
||||
`authorization_code` produced a `Consent required` page mid-flow, which stops any scripted
|
||||
redemption dead. `ClientSettings.builder().requireAuthorizationConsent(false)` is explicit in
|
||||
`AuthServerApplication` for that reason.
|
||||
|
||||
**Spring Cloud is a different release train.** `spring-cloud-dependencies` 2025.1.3 —
|
||||
gateway 5.0.3 — declares `<spring-boot.version>4.0.8</spring-boot.version>` in
|
||||
`spring-cloud-build`. This module runs it under Boot **4.1.1** and it works, but that is a
|
||||
combination nobody tested, and it is the reason a Boot upgrade can be blocked by a gateway.
|
||||
Check `spring-cloud-build`'s POM before assuming the trains are aligned.
|
||||
|
||||
---
|
||||
*Next: [2. Three ways to get a token for the next hop](02-three-ways-to-get-a-token.md)*
|
||||
100
service-to-service/docs/02-three-ways-to-get-a-token.md
Normal file
100
service-to-service/docs/02-three-ways-to-get-a-token.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# 2. Three ways to get a token for the next hop
|
||||
|
||||
*Prev: [1. The four processes](01-the-four-processes.md) · Next: [3. RestClient interceptors](03-restclient-interceptors.md)*
|
||||
|
||||
The edge service has a valid token in its hands and needs to call `downstream`. There are three
|
||||
answers, and [`docs/output/02-five-strategies.txt`](output/02-five-strategies.txt) runs all of
|
||||
them against the same request so the difference is a diff.
|
||||
|
||||
| Endpoint | `sub` downstream sees | `scope` downstream sees |
|
||||
|---|---|---|
|
||||
| `/edge/naive` | — (401) | — |
|
||||
| `/edge/relay` | `alice` | `[orders.write, orders.read]` |
|
||||
| `/edge/client-credentials` | `edge-service` | `[orders.read]` |
|
||||
| `/edge/exchange` | `alice` | `[orders.read]` |
|
||||
| `/edge/relay-async` | — (401) | — |
|
||||
|
||||
## Relay
|
||||
|
||||
Forward the token you were given, unchanged. Ten lines, no dependency on the OAuth2 client
|
||||
machinery:
|
||||
|
||||
```java
|
||||
builder.requestInterceptor((request, body, execution) -> {
|
||||
var authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication instanceof JwtAuthenticationToken token) {
|
||||
request.getHeaders().setBearerAuth(token.getToken().getTokenValue());
|
||||
}
|
||||
return execution.execute(request, body);
|
||||
})
|
||||
```
|
||||
|
||||
Downstream sees `alice`, which is what you want in an audit log. It also sees
|
||||
`orders.write`, which downstream had no business receiving: the token was minted for the
|
||||
request the user made at the edge, and every service it is relayed to gets the union of
|
||||
everything the user consented to. One compromised service holds a credential good against all
|
||||
of them for the rest of its lifetime.
|
||||
|
||||
Relay is right for a hop inside one trust boundary. It is wrong the moment the next hop is
|
||||
operated by someone who should not hold the user's full token.
|
||||
|
||||
## Client credentials
|
||||
|
||||
The service authenticates as itself:
|
||||
|
||||
```yaml
|
||||
spring.security.oauth2.client.registration.edge-service:
|
||||
authorization-grant-type: client_credentials
|
||||
scope: orders.read
|
||||
```
|
||||
|
||||
Downstream now sees `sub: edge-service` and exactly `orders.read`. The scope problem is solved.
|
||||
The user is gone — `downstream`'s log says a service called it, and answering "who ordered
|
||||
this?" requires correlating two logs by a request id you hope somebody propagated.
|
||||
|
||||
## Token exchange (RFC 8693)
|
||||
|
||||
Trade the user's token for one scoped to the next hop, keeping the user:
|
||||
|
||||
```yaml
|
||||
authorization-grant-type: urn:ietf:params:oauth:grant-type:token-exchange
|
||||
scope: orders.read
|
||||
```
|
||||
|
||||
`sub: alice`, `scope: [orders.read]`. Both properties, from one grant type that has existed
|
||||
since 2020 and that almost nobody reaches for.
|
||||
|
||||
The catch is configuration, not concept. `OAuth2AuthorizedClientProviderBuilder`'s defaults do
|
||||
**not** include token exchange, so you have to add the provider yourself:
|
||||
|
||||
```java
|
||||
OAuth2AuthorizedClientProviderBuilder.builder()
|
||||
.clientCredentials()
|
||||
.refreshToken()
|
||||
.provider(new TokenExchangeOAuth2AuthorizedClientProvider())
|
||||
.build();
|
||||
```
|
||||
|
||||
Leave it out and the manager returns `null` rather than raising anything, and the interceptor
|
||||
sends the request with no `Authorization` header at all. A 401 that looks like a downstream
|
||||
problem and is a wiring problem.
|
||||
|
||||
## The trap: relay from another thread
|
||||
|
||||
`/edge/relay-async` does exactly what `/edge/relay` does, on a virtual thread from an executor.
|
||||
It returns 401.
|
||||
|
||||
`SecurityContextHolder` is a `ThreadLocal`. The interceptor reads it; the task runs on a thread
|
||||
that never had a `SecurityContext` written to it; the `instanceof` fails; the header is never
|
||||
set. Nothing throws. This is the same mechanism as
|
||||
[Spring Security Context Propagation](https://ankurm.com/spring-security-context-propagation-complete-guide/),
|
||||
and the cure is the same: `DelegatingSecurityContextExecutorService`, or
|
||||
`ContextPropagatingTaskDecorator`, or capture the token value on the request thread and pass it
|
||||
as a parameter.
|
||||
|
||||
Any relay built on `SecurityContextHolder` inherits every one of those failure modes. A relay
|
||||
built on an explicit token parameter inherits none of them, at the cost of a parameter on every
|
||||
method between the controller and the client.
|
||||
|
||||
---
|
||||
*Prev: [1. The four processes](01-the-four-processes.md) · Next: [3. RestClient interceptors](03-restclient-interceptors.md)*
|
||||
77
service-to-service/docs/03-restclient-interceptors.md
Normal file
77
service-to-service/docs/03-restclient-interceptors.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# 3. `RestClient` interceptors
|
||||
|
||||
*Prev: [2. Three ways to get a token](02-three-ways-to-get-a-token.md) · Next: [4. What a resource server does not validate](04-what-is-not-validated.md)*
|
||||
|
||||
`OAuth2ClientHttpRequestInterceptor`, in
|
||||
`org.springframework.security.oauth2.client.web.client`, is the framework's answer for a
|
||||
`RestClient` that needs a token. Its whole public surface:
|
||||
|
||||
```java
|
||||
public OAuth2ClientHttpRequestInterceptor(OAuth2AuthorizedClientManager manager);
|
||||
public void setAuthorizationFailureHandler(OAuth2AuthorizationFailureHandler handler);
|
||||
public void setClientRegistrationIdResolver(ClientRegistrationIdResolver resolver);
|
||||
public void setPrincipalResolver(PrincipalResolver resolver);
|
||||
```
|
||||
|
||||
Wiring it takes one line, and choosing the registration per call takes one more:
|
||||
|
||||
```java
|
||||
RestClient client = builder
|
||||
.baseUrl("http://127.0.0.1:8082")
|
||||
.requestInterceptor(new OAuth2ClientHttpRequestInterceptor(authorizedClientManager))
|
||||
.build();
|
||||
|
||||
client.get().uri("/orders")
|
||||
.attributes(clientRegistrationId("edge-service")) // static import
|
||||
.retrieve().body(Map.class);
|
||||
```
|
||||
|
||||
`clientRegistrationId(..)` is a static method on `RequestAttributeClientRegistrationIdResolver`.
|
||||
Without it, the default resolver finds nothing and the request goes out unauthenticated.
|
||||
|
||||
## Which `OAuth2AuthorizedClientManager`
|
||||
|
||||
This is the choice that decides whether the thing works off a request thread.
|
||||
|
||||
| Manager | Storage | Needs a request? |
|
||||
|---|---|---|
|
||||
| `DefaultOAuth2AuthorizedClientManager` | `OAuth2AuthorizedClientRepository` (session) | **Yes** |
|
||||
| `AuthorizedClientServiceOAuth2AuthorizedClientManager` | `OAuth2AuthorizedClientService` | No |
|
||||
|
||||
For service-to-service calls there is no end user whose authorization is being stored per
|
||||
session, so the second one is right — and it is the one that keeps working from a scheduled
|
||||
task, a message listener or an `@Async` method.
|
||||
|
||||
Getting this wrong produces `ClientAuthorizationRequiredException` or a silent `null` in a
|
||||
context that has no `HttpServletRequest`, which reads like an OAuth problem and is a bean
|
||||
problem.
|
||||
|
||||
## What it caches, and what it does not
|
||||
|
||||
The manager stores the authorized client (access token and, if issued, refresh token) in the
|
||||
`OAuth2AuthorizedClientService` and reuses it until it is within the clock skew of expiry.
|
||||
So a `client_credentials` registration does **not** hit the token endpoint per request. What it
|
||||
does do is re-request on expiry, synchronously, inside whichever call happens to be first —
|
||||
worth knowing when a latency percentile spikes on a period that matches your token lifetime.
|
||||
|
||||
## The hand-rolled relay, and why it is still reasonable
|
||||
|
||||
The relay interceptor in
|
||||
[`DownstreamClients`](../src/main/java/com/ankurm/s2s/edge/DownstreamClients.java) does not use
|
||||
any of the above:
|
||||
|
||||
```java
|
||||
var authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication instanceof JwtAuthenticationToken token) {
|
||||
request.getHeaders().setBearerAuth(token.getToken().getTokenValue());
|
||||
}
|
||||
```
|
||||
|
||||
That is not a worse version of `OAuth2ClientHttpRequestInterceptor`; it is a different thing.
|
||||
The interceptor **obtains** a token under a client registration. This **forwards** the token
|
||||
already in hand. There is no client registration for "the caller's token", and there should not
|
||||
be. Just do not confuse the two: the hand-rolled one carries the `ThreadLocal` dependency from
|
||||
chapter 2, and the framework one does not.
|
||||
|
||||
---
|
||||
*Prev: [2. Three ways to get a token](02-three-ways-to-get-a-token.md) · Next: [4. What a resource server does not validate](04-what-is-not-validated.md)*
|
||||
125
service-to-service/docs/04-what-is-not-validated.md
Normal file
125
service-to-service/docs/04-what-is-not-validated.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# 4. What a resource server does not validate
|
||||
|
||||
*Prev: [3. RestClient interceptors](03-restclient-interceptors.md) · Next: [5. The gateway](05-the-gateway.md)*
|
||||
|
||||
This is the most important chapter in the module.
|
||||
|
||||
## The demonstration
|
||||
|
||||
`reporting-service` is a client registered on the same authorization server, with nothing to do
|
||||
with the downstream service. Its access tokens carry `aud: reporting-api`.
|
||||
|
||||
Send one to the downstream service, whose audience is `downstream-api`:
|
||||
|
||||
```
|
||||
HTTP/1.1 200
|
||||
{ "sub": "reporting-service", "aud": ["reporting-api"], "scope": "[orders.read]", ... }
|
||||
```
|
||||
|
||||
Two hundred. Full transcript:
|
||||
[`docs/output/03-audience-ignored.txt`](output/03-audience-ignored.txt).
|
||||
|
||||
## Why
|
||||
|
||||
`JwtValidators.createDefault()` is a `DelegatingOAuth2TokenValidator` over three validators,
|
||||
read back by reflection in `ValidatorContractTests.defaultDelegates`:
|
||||
|
||||
- `JwtTypeValidator`
|
||||
- `JwtTimestampValidator`
|
||||
- `X509CertificateThumbprintValidator`
|
||||
|
||||
Structure, expiry, and certificate binding. **No issuer. No audience. No scope.** Setting
|
||||
`spring.security.oauth2.resourceserver.jwt.issuer-uri` gets you a `JwtIssuerValidator` on top of
|
||||
that, so the issuer is covered. Nothing anywhere adds an audience check unless you write it.
|
||||
|
||||
The practical consequence: in an estate where several services trust one authorization server —
|
||||
which is the normal shape — **any token from that issuer is accepted by every service in it**.
|
||||
A token a partner integration obtained for the reporting API is a valid credential for the
|
||||
payments API. Scope may or may not save you; `scope: orders.read` did not, above.
|
||||
|
||||
## The fix, and its price
|
||||
|
||||
```java
|
||||
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwks).build();
|
||||
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
|
||||
JwtValidators.createDefaultWithIssuer(issuer),
|
||||
new JwtAudienceValidator("downstream-api")));
|
||||
```
|
||||
|
||||
`JwtAudienceValidator` is a public class in 7.1. Two lines, and the demonstration above turns
|
||||
into `The aud claim is not valid`.
|
||||
|
||||
The heavier option is the RFC 9068 profile:
|
||||
|
||||
```java
|
||||
JwtValidators.createAtJwtValidator()
|
||||
.issuer("http://127.0.0.1:9000")
|
||||
.audience("downstream-api")
|
||||
.build();
|
||||
```
|
||||
|
||||
That requires `typ`, `exp`, `sub`, `iat`, `jti`, `iss`, `aud` **and `client_id`** to all be
|
||||
present. It is stricter and it is also where two surprises live.
|
||||
|
||||
## Surprise 1: Spring Authorization Server does not emit an RFC 9068 token
|
||||
|
||||
Out of the box its access tokens carry `typ: JWT` in the header and no `client_id` claim. Point
|
||||
`createAtJwtValidator()` at one and every token is rejected. Both are one line in an
|
||||
`OAuth2TokenCustomizer`:
|
||||
|
||||
```java
|
||||
context.getJwsHeader().type("at+jwt");
|
||||
context.getClaims().claim("client_id", clientId);
|
||||
```
|
||||
|
||||
## Surprise 2: making the token compliant breaks every resource server that was not updated
|
||||
|
||||
`NimbusJwtDecoder`'s default JOSE type verifier accepts `JWT` and an absent `typ`, and nothing
|
||||
else. The moment the authorization server starts typing tokens `at+jwt`, every service using
|
||||
Boot's auto-configured decoder answers:
|
||||
|
||||
```
|
||||
WWW-Authenticate: Bearer error="invalid_token",
|
||||
error_description="An error occurred while attempting to decode the Jwt:
|
||||
the given typ value needs to be one of [JWT]"
|
||||
```
|
||||
|
||||
[`docs/output/05-strict-validation.txt`](output/05-strict-validation.txt) has the downstream
|
||||
service accepting the new tokens and the edge service, one hop away and not updated, rejecting
|
||||
them. The message mentions neither RFC 9068 nor the authorization server.
|
||||
|
||||
The fix on the receiving side:
|
||||
|
||||
```java
|
||||
NimbusJwtDecoder.withJwkSetUri(jwks)
|
||||
.jwtProcessorCustomizer((processor) -> processor.setJWSTypeVerifier(
|
||||
new DefaultJOSEObjectTypeVerifier<>(new JOSEObjectType("at+jwt"),
|
||||
JOSEObjectType.JWT, null)))
|
||||
.build();
|
||||
```
|
||||
|
||||
Roll that out **before** you change the authorization server, not after.
|
||||
|
||||
## A message worth recognising twice
|
||||
|
||||
Two components produce almost the same sentence and mean opposite things:
|
||||
|
||||
| Source | Message |
|
||||
|---|---|
|
||||
| Nimbus `DefaultJOSEObjectTypeVerifier` | `the given typ value needs to be one of [JWT]` |
|
||||
| Spring `JwtTypeValidator` (at+jwt profile) | `the given typ value needs to be one of [at+jwt, application/at+jwt]` |
|
||||
|
||||
The first means "your authorization server is too modern for this decoder". The second means
|
||||
"your authorization server is not modern enough for this validator".
|
||||
|
||||
## And one thing that *is* on by default
|
||||
|
||||
`X509CertificateThumbprintValidator` is in the default set. If a token carries a `cnf` claim
|
||||
with `x5t#S256` — a certificate-bound access token, RFC 8705 — Spring Security **already**
|
||||
checks it against the client certificate on the TLS connection, with no configuration. That is
|
||||
the strongest available defence against a stolen bearer token, it costs nothing on the resource
|
||||
server, and it needs mTLS to be terminated in the application rather than in a sidecar. Chapter
|
||||
6.
|
||||
|
||||
---
|
||||
*Prev: [3. RestClient interceptors](03-restclient-interceptors.md) · Next: [5. The gateway](05-the-gateway.md)*
|
||||
74
service-to-service/docs/05-the-gateway.md
Normal file
74
service-to-service/docs/05-the-gateway.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# 5. The gateway, and what `TokenRelay` actually relays
|
||||
|
||||
*Prev: [4. What is not validated](04-what-is-not-validated.md) · Next: [6. mTLS](06-mtls.md)*
|
||||
|
||||
Spring Cloud Gateway Server MVC ships a filter that sounds like it solves the whole problem:
|
||||
|
||||
```yaml
|
||||
filters:
|
||||
- TokenRelay=
|
||||
```
|
||||
|
||||
It is `TokenRelayFilterFunctions`, with two forms:
|
||||
|
||||
```java
|
||||
public static HandlerFilterFunction<ServerResponse, ServerResponse> tokenRelay();
|
||||
public static HandlerFilterFunction<ServerResponse, ServerResponse> tokenRelay(String clientRegistrationId);
|
||||
```
|
||||
|
||||
The documentation is precise about what it does, and it is not what the name suggests to most
|
||||
readers: it takes the access token **of the currently authenticated user** — the one obtained
|
||||
by `oauth2Login()` — or of the named client registration, and puts it in the `Authorization`
|
||||
header of the proxied request.
|
||||
|
||||
It does not mean "forward the incoming bearer token". There is nothing to forward from if the
|
||||
gateway never performed a login.
|
||||
|
||||
## The A/B
|
||||
|
||||
`gateway.yml` defines two routes to the same destination, differing only in the filter.
|
||||
[`docs/output/04-gateway-token-relay.txt`](output/04-gateway-token-relay.txt):
|
||||
|
||||
```
|
||||
/edge/relay (TokenRelay=) → 200, sub: alice
|
||||
/norelay/x (no TokenRelay) → 200, sub: alice
|
||||
```
|
||||
|
||||
Identical. The token arrived because the gateway proxied the `Authorization` header the caller
|
||||
sent, which it would have done anyway. In this configuration `TokenRelay` contributes nothing.
|
||||
|
||||
Where it earns its place is the other shape: a gateway that terminates a **browser session**
|
||||
with `oauth2Login()`, keeps the tokens server-side, hands the browser nothing but a session
|
||||
cookie, and attaches a token on the way through. That is the backend-for-frontend pattern, and
|
||||
it is a genuinely good answer to "where do I keep the token in a SPA" — because the answer is
|
||||
"not in the SPA".
|
||||
|
||||
## The 401 that has nothing to do with OAuth
|
||||
|
||||
Before `GatewaySecurityConfig` existed, every call through the gateway came back:
|
||||
|
||||
```
|
||||
HTTP/1.1 401
|
||||
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
|
||||
```
|
||||
|
||||
A perfectly valid bearer token, rejected by a gateway that had never been told to expect one.
|
||||
Spring Boot applies a default filter chain — HTTP Basic and form login over every path — to any
|
||||
application on the classpath with Spring Security and no `SecurityFilterChain` bean. A gateway
|
||||
is an application. `WWW-Authenticate: Basic` in front of a token-based estate always means this.
|
||||
|
||||
## Choosing where the gateway sits
|
||||
|
||||
| Shape | Gateway does | Downstream sees |
|
||||
|---|---|---|
|
||||
| Pass-through | Routes; validates nothing | The caller's token; each service validates it |
|
||||
| Edge validation | Validates the token, strips it, adds its own identity | The gateway's identity |
|
||||
| BFF | `oauth2Login()`, holds tokens, `TokenRelay` | The user's token, obtained by the gateway |
|
||||
|
||||
Pass-through is the default and is fine while every service validates properly — chapter 4 is
|
||||
about how often that assumption is wrong. Edge validation is the one that tempts teams into
|
||||
"the gateway checked it, so we can trust the header", which is chapter 6's failure mode wearing
|
||||
a different hat.
|
||||
|
||||
---
|
||||
*Prev: [4. What is not validated](04-what-is-not-validated.md) · Next: [6. mTLS](06-mtls.md)*
|
||||
90
service-to-service/docs/06-mtls.md
Normal file
90
service-to-service/docs/06-mtls.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# 6. mTLS: in the mesh or in the application
|
||||
|
||||
*Prev: [5. The gateway](05-the-gateway.md) · Next: [7. Choosing](07-choosing.md)*
|
||||
|
||||
Run `./scripts/certs.sh`, then start `MtlsApplication` on 8443. The certificates it generates
|
||||
are the point: `edge.crt` and `rogue.crt` have **identical subjects** and different issuers.
|
||||
|
||||
```
|
||||
subject=CN = edge-service, OU = payments issuer=CN = Internal Mesh CA
|
||||
subject=CN = edge-service, OU = payments issuer=CN = Some Other CA
|
||||
```
|
||||
|
||||
Identity under mTLS is not the subject. It is the subject plus the fact that a CA in the trust
|
||||
store vouched for it.
|
||||
|
||||
## In-application mTLS
|
||||
|
||||
```yaml
|
||||
server:
|
||||
ssl:
|
||||
bundle: server
|
||||
client-auth: need
|
||||
spring:
|
||||
ssl:
|
||||
bundle:
|
||||
pem:
|
||||
server:
|
||||
keystore: { certificate: "file:...server.crt", private-key: "file:...server.key" }
|
||||
truststore: { certificate: "file:...internal-ca.crt" }
|
||||
```
|
||||
|
||||
```java
|
||||
http.x509((x509) -> x509
|
||||
.subjectPrincipalRegex("CN=([^,]*)(?:,|$)")
|
||||
.userDetailsService(certificateUsers()));
|
||||
```
|
||||
|
||||
A good certificate produces a `PreAuthenticatedAuthenticationToken` with the CN as the
|
||||
principal, and — in Spring Security 7 — a `FACTOR_X509` authority alongside the roles, the
|
||||
sibling of the `FACTOR_BEARER` you get from a JWT and the `FACTOR_PASSWORD` you get from Basic.
|
||||
Any assertion using `containsExactly` on authorities will fail on it.
|
||||
|
||||
## Three things the transcript shows that a diagram does not
|
||||
|
||||
[`docs/output/06-mtls.txt`](output/06-mtls.txt):
|
||||
|
||||
**The rogue certificate produces no HTTP status at all.** `curl` exits 56; there is no response
|
||||
line, no 401, no 403, and nothing in the application log at `INFO`. The handshake failed. Your
|
||||
application-level metrics will show nothing, because from the application's point of view
|
||||
nothing happened. Debugging this means `-Djavax.net.debug=ssl:handshake` or the load balancer's
|
||||
own logs.
|
||||
|
||||
**`client-auth: need` is a property of the connector, not of a path.** `/mtls/trusted-header` is
|
||||
`permitAll()` and it fails exactly the same way without a certificate. You cannot expose a
|
||||
public health endpoint on an mTLS-only connector; it needs a second connector, or `want`
|
||||
instead of `need` plus an explicit authorization rule that treats an absent certificate as
|
||||
anonymous.
|
||||
|
||||
**Header-based identity is verified by nothing.** The last call in the transcript presents a
|
||||
valid certificate for `edge-service` and a header claiming to be `payments-service`, and the
|
||||
endpoint reports `payments-service`.
|
||||
|
||||
## Mesh mTLS
|
||||
|
||||
A service mesh terminates TLS in a sidecar. The application receives plain HTTP on localhost and
|
||||
the peer identity arrives as a header — `X-Forwarded-Client-Cert` in Envoy, carrying the SPIFFE
|
||||
URI SAN. The trade:
|
||||
|
||||
| | Mesh | In-application |
|
||||
|---|---|---|
|
||||
| Certificate lifecycle | Handled, rotated automatically | Yours: issuance, rotation, expiry alerts |
|
||||
| Application code | None | An SSL bundle plus `x509(..)` |
|
||||
| Identity in the app | A header | A verified `X509Certificate` |
|
||||
| Works with cert-bound tokens (RFC 8705) | **No** | Yes |
|
||||
| Failure mode | Anything that bypasses the sidecar can spoof the header | Handshake failure, no HTTP status |
|
||||
|
||||
The row that decides it for a security-sensitive service is the RFC 8705 one. Certificate-bound
|
||||
access tokens — the `cnf` / `x5t#S256` claim, which `X509CertificateThumbprintValidator`
|
||||
already checks by default (chapter 4) — bind a token to the TLS connection it was issued for, so
|
||||
a stolen token is useless without the private key. That binding requires the application to see
|
||||
the client certificate. Terminate mTLS in a sidecar and the strongest defence available against
|
||||
token theft is off the table.
|
||||
|
||||
If you take mesh identity from a header, the header must be **stripped at ingress** on every
|
||||
path into the pod, and the pod must not be reachable except through the proxy. Both are
|
||||
infrastructure guarantees that no amount of application code can verify — which is exactly why
|
||||
`/mtls/trusted-header` answers `"verifiedBy": "nothing. This endpoint believes a header."`.
|
||||
|
||||
---
|
||||
*Prev: [5. The gateway](05-the-gateway.md) · Next: [7. Choosing](07-choosing.md)*
|
||||
53
service-to-service/docs/07-choosing.md
Normal file
53
service-to-service/docs/07-choosing.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 7. Choosing
|
||||
|
||||
*Prev: [6. mTLS](06-mtls.md)*
|
||||
|
||||
## Which propagation strategy
|
||||
|
||||
| Situation | Use |
|
||||
|---|---|
|
||||
| One hop, same team, same trust boundary, audit needs the user | Relay |
|
||||
| A job with no user: scheduler, message listener, reconciliation | Client credentials |
|
||||
| The next hop is another team's service, and audit needs the user | Token exchange |
|
||||
| The next hop is outside your organisation | Client credentials, and a token minted for them |
|
||||
|
||||
The honest default is **relay inside a boundary, exchange across one**. Client credentials is
|
||||
the right answer more often than it is used for work with no user attached, and the wrong answer
|
||||
whenever somebody will later ask "who did this?".
|
||||
|
||||
## Do you need any of this?
|
||||
|
||||
A callout that belongs in every article on this subject and is usually missing:
|
||||
|
||||
> **If your services are a single deployment behind one ingress, all of this is cost with no
|
||||
> benefit.** Two Spring Boot applications in one VPC, called by one another, with no
|
||||
> multi-tenancy and no external partner, do not need an authorization server, a token exchange
|
||||
> grant or a mesh. Network-level isolation plus a shared secret is a defensible design, and it
|
||||
> is a design you can reason about at 3am. The machinery in this repository earns its keep when
|
||||
> there are enough services, enough teams, or enough regulatory pressure that "who called this,
|
||||
> on whose behalf, with what permission" has to be answerable from a log rather than from
|
||||
> memory.
|
||||
|
||||
## A checklist that is short on purpose
|
||||
|
||||
1. Every resource server validates **audience**, not just signature and expiry (chapter 4).
|
||||
2. No relay reads `SecurityContextHolder` from a thread the request did not create (chapter 2).
|
||||
3. Every service has a `SecurityFilterChain` bean, so nothing falls back to Boot's Basic default
|
||||
(chapter 5).
|
||||
4. Client registrations name `token-uri` / `jwk-set-uri` rather than `issuer-uri`, unless you
|
||||
want a startup-ordering dependency (chapter 1).
|
||||
5. If identity arrives in a header, something upstream strips that header from every external
|
||||
request, and you can name the component that does it (chapter 6).
|
||||
6. Token lifetimes are short enough that the audience gap in item 1 is bounded even when
|
||||
somebody forgets.
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Spring Security Context Propagation](https://ankurm.com/spring-security-context-propagation-complete-guide/) — why the async relay returns 401
|
||||
- [The Spring Security Filter Chain Explained](https://ankurm.com/spring-security-filter-chain-explained/) — where `BearerTokenAuthenticationFilter` sits
|
||||
- [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) — token exchange
|
||||
- [RFC 9068](https://datatracker.ietf.org/doc/html/rfc9068) — the `at+jwt` access token profile
|
||||
- [RFC 8705](https://datatracker.ietf.org/doc/html/rfc8705) — mTLS client authentication and certificate-bound access tokens
|
||||
|
||||
---
|
||||
*Prev: [6. mTLS](06-mtls.md)*
|
||||
26
service-to-service/docs/output/01-user-token.txt
Normal file
26
service-to-service/docs/output/01-user-token.txt
Normal file
@@ -0,0 +1,26 @@
|
||||
==============================================================================
|
||||
docs/output/01-user-token.txt
|
||||
A complete authorization_code + PKCE flow, driven by curl. No browser, no OIDC library.
|
||||
scripts/user-token.sh, then scripts/claims.sh
|
||||
==============================================================================
|
||||
|
||||
{
|
||||
"alg": "RS256",
|
||||
"kid": "<uuid>"
|
||||
}
|
||||
{
|
||||
"aud": "downstream-api",
|
||||
"exp": <epoch>,
|
||||
"iat": <epoch>,
|
||||
"iss": "http://127.0.0.1:9000",
|
||||
"jti": "<uuid>",
|
||||
"nbf": <epoch>,
|
||||
"scope": [
|
||||
"orders.write",
|
||||
"orders.read"
|
||||
],
|
||||
"sub": "alice"
|
||||
}
|
||||
|
||||
# sub is the human. scope is what the human consented to. aud names the service the
|
||||
# token was minted for - chapter 4 is about whether anybody looks at it.
|
||||
110
service-to-service/docs/output/02-five-strategies.txt
Normal file
110
service-to-service/docs/output/02-five-strategies.txt
Normal file
@@ -0,0 +1,110 @@
|
||||
==============================================================================
|
||||
docs/output/02-five-strategies.txt
|
||||
The same request into the edge service, five ways of getting a token for the hop to
|
||||
downstream. Read the sub and scope of each downstream response.
|
||||
GET /edge/{naive,relay,client-credentials,exchange,relay-async}
|
||||
==============================================================================
|
||||
|
||||
$ curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8081/edge/naive
|
||||
{
|
||||
"strategy": "no token forwarded",
|
||||
"error": "Unauthorized: 401 Unauthorized: [no body]"
|
||||
}
|
||||
|
||||
$ curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8081/edge/relay
|
||||
{
|
||||
"strategy": "bearer token relayed from the incoming request",
|
||||
"downstream": {
|
||||
"service": "downstream:8082",
|
||||
"strictValidation": false,
|
||||
"sub": "alice",
|
||||
"aud": [
|
||||
"downstream-api"
|
||||
],
|
||||
"iss": "http://127.0.0.1:9000",
|
||||
"scope": "[orders.write, orders.read]",
|
||||
"client_id": null,
|
||||
"authorities": [
|
||||
"FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=<timestamp>]",
|
||||
"SCOPE_orders.read",
|
||||
"SCOPE_orders.write"
|
||||
],
|
||||
"cnf": null,
|
||||
"orders": [
|
||||
{
|
||||
"total": "42.00",
|
||||
"id": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
$ curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8081/edge/client-credentials
|
||||
{
|
||||
"strategy": "the edge service's own client_credentials token",
|
||||
"downstream": {
|
||||
"service": "downstream:8082",
|
||||
"strictValidation": false,
|
||||
"sub": "edge-service",
|
||||
"aud": [
|
||||
"downstream-api"
|
||||
],
|
||||
"iss": "http://127.0.0.1:9000",
|
||||
"scope": "[orders.read]",
|
||||
"client_id": null,
|
||||
"authorities": [
|
||||
"FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=<timestamp>]",
|
||||
"SCOPE_orders.read"
|
||||
],
|
||||
"cnf": null,
|
||||
"orders": [
|
||||
{
|
||||
"total": "42.00",
|
||||
"id": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
$ curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8081/edge/exchange
|
||||
{
|
||||
"strategy": "RFC 8693 token exchange",
|
||||
"downstream": {
|
||||
"service": "downstream:8082",
|
||||
"strictValidation": false,
|
||||
"sub": "alice",
|
||||
"aud": [
|
||||
"downstream-api"
|
||||
],
|
||||
"iss": "http://127.0.0.1:9000",
|
||||
"scope": "[orders.read]",
|
||||
"client_id": null,
|
||||
"authorities": [
|
||||
"SCOPE_orders.read",
|
||||
"FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=<timestamp>]"
|
||||
],
|
||||
"cnf": null,
|
||||
"orders": [
|
||||
{
|
||||
"total": "42.00",
|
||||
"id": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
$ curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8081/edge/relay-async
|
||||
{
|
||||
"strategy": "relay attempted from a separate thread",
|
||||
"error": "Unauthorized: 401 Unauthorized: [no body]"
|
||||
}
|
||||
|
||||
# naive - 401. The control.
|
||||
# relay - sub: alice, scope: [orders.write, orders.read]. The user's own
|
||||
# token, unchanged, including scopes downstream did not need.
|
||||
# client-creds - sub: edge-service, scope: [orders.read]. Correctly scoped, and
|
||||
# the user has disappeared from downstream's audit log.
|
||||
# exchange - sub: alice, scope: [orders.read]. Both. This is what RFC 8693 is
|
||||
# for and it is the one nobody reaches for.
|
||||
# relay-async - 401. The relay interceptor reads SecurityContextHolder, which is
|
||||
# a ThreadLocal, and the call was made on a different thread.
|
||||
53
service-to-service/docs/output/03-audience-ignored.txt
Normal file
53
service-to-service/docs/output/03-audience-ignored.txt
Normal file
@@ -0,0 +1,53 @@
|
||||
==============================================================================
|
||||
docs/output/03-audience-ignored.txt
|
||||
A token minted for a DIFFERENT service, presented to the downstream service.
|
||||
Default validators.
|
||||
==============================================================================
|
||||
|
||||
# the token reporting-service was issued:
|
||||
{
|
||||
"alg": "RS256",
|
||||
"kid": "<uuid>"
|
||||
}
|
||||
{
|
||||
"aud": "reporting-api",
|
||||
"exp": <epoch>,
|
||||
"iat": <epoch>,
|
||||
"iss": "http://127.0.0.1:9000",
|
||||
"jti": "<uuid>",
|
||||
"nbf": <epoch>,
|
||||
"scope": [
|
||||
"orders.read"
|
||||
],
|
||||
"sub": "reporting-service"
|
||||
}
|
||||
|
||||
$ curl -H 'Authorization: Bearer <reporting-service token>' 127.0.0.1:8082/orders
|
||||
{
|
||||
"service": "downstream:8082",
|
||||
"strictValidation": false,
|
||||
"sub": "reporting-service",
|
||||
"aud": [
|
||||
"reporting-api"
|
||||
],
|
||||
"iss": "http://127.0.0.1:9000",
|
||||
"scope": "[orders.read]",
|
||||
"client_id": null,
|
||||
"authorities": [
|
||||
"FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=<timestamp>]",
|
||||
"SCOPE_orders.read"
|
||||
],
|
||||
"cnf": null,
|
||||
"orders": [
|
||||
{
|
||||
"total": "42.00",
|
||||
"id": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# HTTP 200. The aud claim says reporting-api. The service is downstream-api.
|
||||
# JwtValidators.createDefault() is a DelegatingOAuth2TokenValidator over three
|
||||
# validators - JwtTypeValidator, JwtTimestampValidator and
|
||||
# X509CertificateThumbprintValidator. Structure, expiry, and certificate binding.
|
||||
# No issuer. No audience. Read back by reflection in ValidatorContractTests.
|
||||
80
service-to-service/docs/output/04-gateway-token-relay.txt
Normal file
80
service-to-service/docs/output/04-gateway-token-relay.txt
Normal file
@@ -0,0 +1,80 @@
|
||||
==============================================================================
|
||||
docs/output/04-gateway-token-relay.txt
|
||||
Spring Cloud Gateway Server MVC with 'filters: - TokenRelay='.
|
||||
GET /edge/relay through the gateway on 8080.
|
||||
==============================================================================
|
||||
|
||||
$ curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8080/edge/relay
|
||||
HTTP/1.1 200
|
||||
cache-control: no-cache, no-store, max-age=0, must-revalidate
|
||||
expires: 0
|
||||
pragma: no-cache
|
||||
x-content-type-options: nosniff
|
||||
x-frame-options: DENY
|
||||
x-xss-protection: 0
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"strategy": "bearer token relayed from the incoming request",
|
||||
"downstream": {
|
||||
"service": "downstream:8082",
|
||||
"strictValidation": false,
|
||||
"sub": "alice",
|
||||
"aud": [
|
||||
"downstream-api"
|
||||
],
|
||||
"iss": "http://127.0.0.1:9000",
|
||||
"scope": "[orders.write, orders.read]",
|
||||
"client_id": null,
|
||||
"authorities": [
|
||||
"SCOPE_orders.read",
|
||||
"FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=<timestamp>]",
|
||||
"SCOPE_orders.write"
|
||||
],
|
||||
"cnf": null,
|
||||
"orders": [
|
||||
{
|
||||
"total": "42.00",
|
||||
"id": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
$ curl 127.0.0.1:8080/edge/relay # no Authorization header at all
|
||||
status 401
|
||||
|
||||
# and the identical route with the TokenRelay filter REMOVED:
|
||||
$ curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8080/norelay/x
|
||||
{
|
||||
"strategy": "bearer token relayed from the incoming request",
|
||||
"downstream": {
|
||||
"service": "downstream:8082",
|
||||
"strictValidation": false,
|
||||
"sub": "alice",
|
||||
"aud": [
|
||||
"downstream-api"
|
||||
],
|
||||
"iss": "http://127.0.0.1:9000",
|
||||
"scope": "[orders.write, orders.read]",
|
||||
"client_id": null,
|
||||
"authorities": [
|
||||
"SCOPE_orders.read",
|
||||
"FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=<timestamp>]",
|
||||
"SCOPE_orders.write"
|
||||
],
|
||||
"cnf": null,
|
||||
"orders": [
|
||||
{
|
||||
"total": "42.00",
|
||||
"id": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
# TokenRelay relays the access token of the currently authenticated USER - the one
|
||||
# obtained by oauth2Login(). This gateway has no oauth2Login, so there is no
|
||||
# authorized client to read a token from, and the filter contributes nothing. What
|
||||
# reaches the edge service is whatever Authorization header the caller sent, because
|
||||
# the gateway proxied it. TokenRelay is not 'forward the incoming bearer token'.
|
||||
43
service-to-service/docs/output/05-strict-validation.txt
Normal file
43
service-to-service/docs/output/05-strict-validation.txt
Normal file
@@ -0,0 +1,43 @@
|
||||
==============================================================================
|
||||
docs/output/05-strict-validation.txt
|
||||
The same tokens against JwtValidators.createAtJwtValidator().issuer(..).audience(..),
|
||||
with the authorization server emitting RFC 9068 tokens (typ: at+jwt, client_id claim).
|
||||
STRICT=true ./scripts/run.sh
|
||||
==============================================================================
|
||||
|
||||
# the wrong-audience token that was accepted in 03:
|
||||
HTTP/1.1 401
|
||||
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: The aud claim is not valid", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://127.0.0.1:8082/.well-known/oauth-protected-resource"
|
||||
|
||||
# a token minted for this service:
|
||||
{
|
||||
"service": "downstream:8082",
|
||||
"strictValidation": true,
|
||||
"sub": "edge-service",
|
||||
"aud": [
|
||||
"downstream-api"
|
||||
],
|
||||
"iss": "http://127.0.0.1:9000",
|
||||
"scope": "[orders.read]",
|
||||
"client_id": "edge-service",
|
||||
"authorities": [
|
||||
"FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=<timestamp>]",
|
||||
"SCOPE_orders.read"
|
||||
],
|
||||
"cnf": null,
|
||||
"orders": [
|
||||
{
|
||||
"id": 1,
|
||||
"total": "42.00"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# and the edge service, which was NOT updated - it still uses Boot's
|
||||
# auto-configured decoder:
|
||||
HTTP/1.1 401
|
||||
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: the given typ value needs to be one of [JWT]", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://127.0.0.1:8081/.well-known/oauth-protected-resource"
|
||||
|
||||
# Turning on RFC 9068 at the authorization server broke every resource server that
|
||||
# still has NimbusJwtDecoder's default JOSE type verifier, and the error message
|
||||
# mentions neither RFC 9068 nor the authorization server.
|
||||
43
service-to-service/docs/output/06-mtls.txt
Normal file
43
service-to-service/docs/output/06-mtls.txt
Normal file
@@ -0,0 +1,43 @@
|
||||
==============================================================================
|
||||
docs/output/06-mtls.txt
|
||||
Client-certificate authentication on port 8443, server.ssl.client-auth=need.
|
||||
Certificates from scripts/certs.sh. edge.crt and rogue.crt have IDENTICAL subjects and
|
||||
different issuers.
|
||||
==============================================================================
|
||||
|
||||
$ openssl x509 -in target/certs/edge.crt -noout -subject -issuer
|
||||
subject=CN = edge-service, OU = payments
|
||||
issuer=CN = Internal Mesh CA
|
||||
$ openssl x509 -in target/certs/rogue.crt -noout -subject -issuer
|
||||
subject=CN = edge-service, OU = payments
|
||||
issuer=CN = Some Other CA
|
||||
|
||||
$ curl --cert edge.crt --key edge.key https://localhost:8443/mtls/whoami
|
||||
{
|
||||
"principal": "edge-service",
|
||||
"authenticationType": "PreAuthenticatedAuthenticationToken",
|
||||
"authorities": [
|
||||
"ROLE_SERVICE",
|
||||
"FACTOR_X509"
|
||||
],
|
||||
"certificateSubject": "OU=payments,CN=edge-service",
|
||||
"certificateIssuer": "CN=Internal Mesh CA"
|
||||
}
|
||||
|
||||
$ curl --cert rogue.crt --key rogue.key https://localhost:8443/mtls/whoami
|
||||
[curl exit 56, http 000]
|
||||
|
||||
$ curl https://localhost:8443/mtls/trusted-header # a permitAll() endpoint
|
||||
[curl exit 56, http 000]
|
||||
|
||||
$ curl --cert edge.crt --key edge.key -H 'X-Client-Cert-Subject: CN=payments-service' \
|
||||
https://localhost:8443/mtls/trusted-header
|
||||
{"caller":"CN=payments-service","verifiedBy":"nothing. This endpoint believes a header."}
|
||||
|
||||
# Three things worth reading twice.
|
||||
# 1. The rogue certificate fails with curl exit 56 and NO http status. The handshake
|
||||
# is rejected; the application never sees a request and logs nothing at INFO.
|
||||
# 2. So does the permitAll() endpoint. client-auth=need is a property of the
|
||||
# CONNECTOR, not of a path. You cannot expose a public endpoint on that port.
|
||||
# 3. The last call is what a mesh deployment usually looks like from inside the
|
||||
# application: an identity taken from a header, verified by nothing.
|
||||
8
service-to-service/docs/output/07-tests.txt
Normal file
8
service-to-service/docs/output/07-tests.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
==============================================================================
|
||||
docs/output/07-tests.txt
|
||||
mvn -B test
|
||||
==============================================================================
|
||||
|
||||
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.225 s -- in com.ankurm.s2s.ValidatorContractTests
|
||||
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
|
||||
[INFO] BUILD SUCCESS
|
||||
82
service-to-service/pom.xml
Normal file
82
service-to-service/pom.xml
Normal file
@@ -0,0 +1,82 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<!-- Same parent as the other servlet modules in this repository, so the whole series runs
|
||||
on one verified stack. Spring Cloud is a separate release train with its own Boot
|
||||
baseline - see docs/01-the-four-processes.md for what that costs. -->
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.1.1</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>service-to-service</artifactId>
|
||||
<version>1.0</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<spring-cloud.version>2025.1.3</spring-cloud.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-dependencies</artifactId>
|
||||
<version>${spring-cloud.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<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-oauth2-resource-server</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-oauth2-client</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-gateway-server-webmvc</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>
|
||||
36
service-to-service/scripts/certs.sh
Executable file
36
service-to-service/scripts/certs.sh
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generate a throwaway CA, a server certificate for the downstream service, and two client
|
||||
# certificates - one signed by that CA and one signed by a different CA. Into target/, so
|
||||
# nothing here is committed and nothing here should ever be trusted.
|
||||
#
|
||||
# ./scripts/certs.sh
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
D=target/certs
|
||||
rm -rf "$D" && mkdir -p "$D"
|
||||
cd "$D"
|
||||
|
||||
gen_ca() { # gen_ca <name> <cn>
|
||||
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
|
||||
-keyout "$1-ca.key" -out "$1-ca.crt" -subj "/CN=$2" 2>/dev/null
|
||||
}
|
||||
|
||||
sign() { # sign <ca> <name> <subject> [extfile-content]
|
||||
openssl req -newkey rsa:2048 -nodes -keyout "$2.key" -out "$2.csr" -subj "$3" 2>/dev/null
|
||||
if [ -n "${4:-}" ]; then printf '%s\n' "$4" > "$2.ext"; else : > "$2.ext"; fi
|
||||
openssl x509 -req -in "$2.csr" -CA "$1-ca.crt" -CAkey "$1-ca.key" -CAcreateserial \
|
||||
-out "$2.crt" -days 3650 -extfile "$2.ext" 2>/dev/null
|
||||
}
|
||||
|
||||
gen_ca internal "Internal Mesh CA"
|
||||
gen_ca other "Some Other CA"
|
||||
|
||||
sign internal server "/CN=localhost" "subjectAltName=DNS:localhost,IP:127.0.0.1"
|
||||
sign internal edge "/CN=edge-service/OU=payments"
|
||||
sign other rogue "/CN=edge-service/OU=payments"
|
||||
|
||||
echo "wrote:"
|
||||
ls -1 *.crt *.key | sed 's/^/ target\/certs\//'
|
||||
echo
|
||||
echo "Note that rogue.crt carries the SAME subject as edge.crt. Identity in mTLS is not the"
|
||||
echo "subject; it is the subject plus the fact that a trusted CA vouched for it."
|
||||
12
service-to-service/scripts/claims.sh
Executable file
12
service-to-service/scripts/claims.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# Print the header and payload of a JWT without verifying it. For reading, not for trusting.
|
||||
#
|
||||
# ./scripts/claims.sh "$TOKEN"
|
||||
set -eu
|
||||
python3 - "$1" <<'PY'
|
||||
import base64, json, sys
|
||||
token = sys.argv[1]
|
||||
for part in token.split('.')[:2]:
|
||||
padded = part + '=' * (-len(part) % 4)
|
||||
print(json.dumps(json.loads(base64.urlsafe_b64decode(padded)), indent=2, sort_keys=True))
|
||||
PY
|
||||
214
service-to-service/scripts/run-all.sh
Executable file
214
service-to-service/scripts/run-all.sh
Executable file
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerates every file under docs/output/ from a real run. Nothing in docs/output/ is
|
||||
# hand-written; if a claim in the article disagrees with a file here, the file is right.
|
||||
#
|
||||
# ./scripts/run-all.sh
|
||||
#
|
||||
# The whole module is started twice - once loose, once strict - plus a separate mTLS process,
|
||||
# because the difference between those runs IS the content.
|
||||
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))"; }
|
||||
|
||||
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/"(exp|iat|nbf)": [0-9]+/"\1": <epoch>/g' \
|
||||
-e 's/"(jti|kid)": "[0-9a-f-]+"/"\1": "<uuid>"/g' \
|
||||
-e 's/(JSESSIONID=)[0-9A-F]+/\1<session>/g' \
|
||||
-e 's/issuedAt=[^]]*\]/issuedAt=<timestamp>]/g' \
|
||||
-e 's/ [0-9]+ --- / <pid> --- /g' \
|
||||
-e '/Picked up JAVA_TOOL_OPTIONS/d' \
|
||||
| cat -s
|
||||
}
|
||||
|
||||
claims() { ./scripts/claims.sh "$1"; }
|
||||
|
||||
json() { python3 -m json.tool 2>/dev/null || cat; }
|
||||
|
||||
cc_token() { # cc_token <client> <secret>
|
||||
curl -s -u "$1:$2" -X POST http://127.0.0.1:9000/oauth2/token \
|
||||
-d grant_type=client_credentials -d scope=orders.read \
|
||||
| python3 -c 'import json,sys; print(json.load(sys.stdin).get("access_token",""))'
|
||||
}
|
||||
|
||||
########################################################################################
|
||||
# LOOSE RUN
|
||||
########################################################################################
|
||||
./scripts/run.sh > /dev/null 2>&1
|
||||
USER_TOKEN=$(./scripts/user-token.sh)
|
||||
|
||||
{
|
||||
hdr "docs/output/01-user-token.txt
|
||||
A complete authorization_code + PKCE flow, driven by curl. No browser, no OIDC library.
|
||||
scripts/user-token.sh, then scripts/claims.sh"
|
||||
claims "$USER_TOKEN"
|
||||
echo
|
||||
echo "# sub is the human. scope is what the human consented to. aud names the service the"
|
||||
echo "# token was minted for - chapter 4 is about whether anybody looks at it."
|
||||
} | scrub > "$OUT/01-user-token.txt"
|
||||
|
||||
{
|
||||
hdr "docs/output/02-five-strategies.txt
|
||||
The same request into the edge service, five ways of getting a token for the hop to
|
||||
downstream. Read the sub and scope of each downstream response.
|
||||
GET /edge/{naive,relay,client-credentials,exchange,relay-async}"
|
||||
for endpoint in naive relay client-credentials exchange relay-async; do
|
||||
echo "\$ curl -H \"Authorization: Bearer \$TOKEN\" 127.0.0.1:8081/edge/$endpoint"
|
||||
curl -s -H "Authorization: Bearer $USER_TOKEN" "http://127.0.0.1:8081/edge/$endpoint" | json
|
||||
echo
|
||||
done
|
||||
echo "# naive - 401. The control."
|
||||
echo "# relay - sub: alice, scope: [orders.write, orders.read]. The user's own"
|
||||
echo "# token, unchanged, including scopes downstream did not need."
|
||||
echo "# client-creds - sub: edge-service, scope: [orders.read]. Correctly scoped, and"
|
||||
echo "# the user has disappeared from downstream's audit log."
|
||||
echo "# exchange - sub: alice, scope: [orders.read]. Both. This is what RFC 8693 is"
|
||||
echo "# for and it is the one nobody reaches for."
|
||||
echo "# relay-async - 401. The relay interceptor reads SecurityContextHolder, which is"
|
||||
echo "# a ThreadLocal, and the call was made on a different thread."
|
||||
} | scrub > "$OUT/02-five-strategies.txt"
|
||||
|
||||
{
|
||||
hdr "docs/output/03-audience-ignored.txt
|
||||
A token minted for a DIFFERENT service, presented to the downstream service.
|
||||
Default validators."
|
||||
WRONG=$(cc_token reporting-service reporting-secret)
|
||||
echo "# the token reporting-service was issued:"
|
||||
claims "$WRONG"
|
||||
echo
|
||||
echo "\$ curl -H 'Authorization: Bearer <reporting-service token>' 127.0.0.1:8082/orders"
|
||||
curl -s -H "Authorization: Bearer $WRONG" http://127.0.0.1:8082/orders | json
|
||||
echo
|
||||
echo "# HTTP 200. The aud claim says reporting-api. The service is downstream-api."
|
||||
echo "# JwtValidators.createDefault() is a DelegatingOAuth2TokenValidator over three"
|
||||
echo "# validators - JwtTypeValidator, JwtTimestampValidator and"
|
||||
echo "# X509CertificateThumbprintValidator. Structure, expiry, and certificate binding."
|
||||
echo "# No issuer. No audience. Read back by reflection in ValidatorContractTests."
|
||||
} | scrub > "$OUT/03-audience-ignored.txt"
|
||||
|
||||
{
|
||||
hdr "docs/output/04-gateway-token-relay.txt
|
||||
Spring Cloud Gateway Server MVC with 'filters: - TokenRelay='.
|
||||
GET /edge/relay through the gateway on 8080."
|
||||
echo "\$ curl -H \"Authorization: Bearer \$TOKEN\" 127.0.0.1:8080/edge/relay"
|
||||
curl -s -i -H "Authorization: Bearer $USER_TOKEN" http://127.0.0.1:8080/edge/relay \
|
||||
| sed -n '1,/^\r$/p' | grep -viE '^(date|keep-alive|connection|content-length|transfer-encoding):'
|
||||
curl -s -H "Authorization: Bearer $USER_TOKEN" http://127.0.0.1:8080/edge/relay | json
|
||||
echo
|
||||
echo "\$ curl 127.0.0.1:8080/edge/relay # no Authorization header at all"
|
||||
curl -s -o /dev/null -w 'status %{http_code}\n' http://127.0.0.1:8080/edge/relay
|
||||
echo
|
||||
echo "# and the identical route with the TokenRelay filter REMOVED:"
|
||||
echo "\$ curl -H \"Authorization: Bearer \$TOKEN\" 127.0.0.1:8080/norelay/x"
|
||||
curl -s -H "Authorization: Bearer $USER_TOKEN" http://127.0.0.1:8080/norelay/x | json
|
||||
echo
|
||||
echo "# TokenRelay relays the access token of the currently authenticated USER - the one"
|
||||
echo "# obtained by oauth2Login(). This gateway has no oauth2Login, so there is no"
|
||||
echo "# authorized client to read a token from, and the filter contributes nothing. What"
|
||||
echo "# reaches the edge service is whatever Authorization header the caller sent, because"
|
||||
echo "# the gateway proxied it. TokenRelay is not 'forward the incoming bearer token'."
|
||||
} | scrub > "$OUT/04-gateway-token-relay.txt"
|
||||
|
||||
########################################################################################
|
||||
# STRICT RUN
|
||||
########################################################################################
|
||||
STRICT=true ./scripts/run.sh > /dev/null 2>&1
|
||||
{
|
||||
hdr "docs/output/05-strict-validation.txt
|
||||
The same tokens against JwtValidators.createAtJwtValidator().issuer(..).audience(..),
|
||||
with the authorization server emitting RFC 9068 tokens (typ: at+jwt, client_id claim).
|
||||
STRICT=true ./scripts/run.sh"
|
||||
WRONG=$(cc_token reporting-service reporting-secret)
|
||||
RIGHT=$(cc_token edge-service edge-secret)
|
||||
echo "# the wrong-audience token that was accepted in 03:"
|
||||
curl -s -i -H "Authorization: Bearer $WRONG" http://127.0.0.1:8082/orders \
|
||||
| grep -iE '^(HTTP|WWW-Authenticate)'
|
||||
echo
|
||||
echo "# a token minted for this service:"
|
||||
curl -s -H "Authorization: Bearer $RIGHT" http://127.0.0.1:8082/orders | json
|
||||
echo
|
||||
echo "# and the edge service, which was NOT updated - it still uses Boot's"
|
||||
echo "# auto-configured decoder:"
|
||||
STRICT_USER=$(./scripts/user-token.sh)
|
||||
curl -s -i -H "Authorization: Bearer $STRICT_USER" http://127.0.0.1:8081/edge/relay \
|
||||
| grep -iE '^(HTTP|WWW-Authenticate)'
|
||||
echo
|
||||
echo "# Turning on RFC 9068 at the authorization server broke every resource server that"
|
||||
echo "# still has NimbusJwtDecoder's default JOSE type verifier, and the error message"
|
||||
echo "# mentions neither RFC 9068 nor the authorization server."
|
||||
} | scrub > "$OUT/05-strict-validation.txt"
|
||||
|
||||
./scripts/stop.sh
|
||||
|
||||
########################################################################################
|
||||
# mTLS
|
||||
########################################################################################
|
||||
./scripts/certs.sh > /dev/null
|
||||
CP="target/classes:$(cat target/cp.txt)"
|
||||
setsid nohup java -Xmx160m -cp "$CP" com.ankurm.s2s.mtls.MtlsApplication \
|
||||
> /tmp/s2s-Mtls.log 2>&1 < /dev/null &
|
||||
for _ in $(seq 1 60); do
|
||||
curl -s -o /dev/null -m 2 --cacert target/certs/internal-ca.crt \
|
||||
--cert target/certs/edge.crt --key target/certs/edge.key \
|
||||
https://localhost:8443/mtls/whoami && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
{
|
||||
hdr "docs/output/06-mtls.txt
|
||||
Client-certificate authentication on port 8443, server.ssl.client-auth=need.
|
||||
Certificates from scripts/certs.sh. edge.crt and rogue.crt have IDENTICAL subjects and
|
||||
different issuers."
|
||||
echo "\$ openssl x509 -in target/certs/edge.crt -noout -subject -issuer"
|
||||
openssl x509 -in target/certs/edge.crt -noout -subject -issuer
|
||||
echo "\$ openssl x509 -in target/certs/rogue.crt -noout -subject -issuer"
|
||||
openssl x509 -in target/certs/rogue.crt -noout -subject -issuer
|
||||
echo
|
||||
echo "\$ curl --cert edge.crt --key edge.key https://localhost:8443/mtls/whoami"
|
||||
curl -s --cacert target/certs/internal-ca.crt --cert target/certs/edge.crt \
|
||||
--key target/certs/edge.key https://localhost:8443/mtls/whoami | json
|
||||
echo
|
||||
echo "\$ curl --cert rogue.crt --key rogue.key https://localhost:8443/mtls/whoami"
|
||||
curl -s --cacert target/certs/internal-ca.crt --cert target/certs/rogue.crt \
|
||||
--key target/certs/rogue.key https://localhost:8443/mtls/whoami \
|
||||
-w '[curl exit %{exitcode}, http %{http_code}]\n' 2>&1 | tail -1
|
||||
echo
|
||||
echo "\$ curl https://localhost:8443/mtls/trusted-header # a permitAll() endpoint"
|
||||
curl -sk https://localhost:8443/mtls/trusted-header \
|
||||
-w '[curl exit %{exitcode}, http %{http_code}]\n' 2>&1 | tail -1
|
||||
echo
|
||||
echo "\$ curl --cert edge.crt --key edge.key -H 'X-Client-Cert-Subject: CN=payments-service' \\"
|
||||
echo " https://localhost:8443/mtls/trusted-header"
|
||||
curl -s --cacert target/certs/internal-ca.crt --cert target/certs/edge.crt \
|
||||
--key target/certs/edge.key -H 'X-Client-Cert-Subject: CN=payments-service' \
|
||||
https://localhost:8443/mtls/trusted-header
|
||||
echo
|
||||
echo
|
||||
echo "# Three things worth reading twice."
|
||||
echo "# 1. The rogue certificate fails with curl exit 56 and NO http status. The handshake"
|
||||
echo "# is rejected; the application never sees a request and logs nothing at INFO."
|
||||
echo "# 2. So does the permitAll() endpoint. client-auth=need is a property of the"
|
||||
echo "# CONNECTOR, not of a path. You cannot expose a public endpoint on that port."
|
||||
echo "# 3. The last call is what a mesh deployment usually looks like from inside the"
|
||||
echo "# application: an identity taken from a header, verified by nothing."
|
||||
} | scrub > "$OUT/06-mtls.txt"
|
||||
|
||||
for pid in $(ps -eo pid,ppid,comm,args | awk '$3 ~ /^java/ && $0 ~ /com\.ankurm\.s2s\.mtls/ {print $1}'); do
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
done
|
||||
|
||||
########################################################################################
|
||||
# TESTS
|
||||
########################################################################################
|
||||
{
|
||||
hdr "docs/output/07-tests.txt
|
||||
mvn -B test"
|
||||
mvn -B test 2>&1 | grep -E 'Tests run|ERROR|BUILD' | head -30
|
||||
} | scrub > "$OUT/07-tests.txt"
|
||||
|
||||
echo "regenerated $(ls "$OUT" | wc -l) files under $OUT"
|
||||
64
service-to-service/scripts/run.sh
Executable file
64
service-to-service/scripts/run.sh
Executable file
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# Start all four processes and wait until each answers.
|
||||
#
|
||||
# ./scripts/run.sh
|
||||
# STRICT=true ./scripts/run.sh # downstream validates issuer and audience
|
||||
# RS_LOG_LEVEL=DEBUG ./scripts/run.sh # resource-server decisions at DEBUG
|
||||
#
|
||||
# | Process | Port | Main class |
|
||||
# |-------------|------|---------------------------|
|
||||
# | authserver | 9000 | AuthServerApplication |
|
||||
# | gateway | 8080 | GatewayApplication |
|
||||
# | edge | 8081 | EdgeApplication |
|
||||
# | downstream | 8082 | DownstreamApplication |
|
||||
#
|
||||
# These are launched with plain `java`, not `spring-boot:run`. Four Maven JVMs each forking an
|
||||
# application JVM is eight processes, and on a small machine that is how you meet the OOM
|
||||
# killer rather than the demo. `mvn dependency:build-classpath` once, then `java -cp` four
|
||||
# times, is two hundred megabytes of heap instead of two gigabytes.
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
./scripts/stop.sh
|
||||
mvn -B -q compile
|
||||
if [ ! -f target/cp.txt ]; then
|
||||
mvn -B -q dependency:build-classpath -Dmdep.outputFile=target/cp.txt -Dmdep.includeScope=runtime
|
||||
fi
|
||||
CP="target/classes:$(cat target/cp.txt)"
|
||||
|
||||
start() { # start <main-class> <port> <health-path> <extra-jvm-args...>
|
||||
local main="$1" port="$2" path="$3"
|
||||
shift 3
|
||||
setsid nohup java -Xmx192m -XX:TieredStopAtLevel=1 "$@" ${JVM_ARGS:-} \
|
||||
-cp "$CP" "com.ankurm.s2s.$main" \
|
||||
> "/tmp/s2s-${main##*.}.log" 2>&1 < /dev/null &
|
||||
for _ in $(seq 1 60); do
|
||||
if curl -s -o /dev/null "http://127.0.0.1:$port$path" 2>/dev/null; then
|
||||
echo " ${main##*.} up on $port"
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "${main##*.} did not start; see /tmp/s2s-${main##*.}.log" >&2
|
||||
tail -20 "/tmp/s2s-${main##*.}.log" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# STRICT=true turns on RFC 9068 end to end: the authorization server types its access tokens
|
||||
# `at+jwt` and adds a client_id claim, and the downstream service validates issuer, audience
|
||||
# and the required-claim set instead of only signature and expiry.
|
||||
STRICT_ARGS=()
|
||||
AS_ARGS=()
|
||||
if [ "${STRICT:-false}" = "true" ]; then
|
||||
STRICT_ARGS=(-DSTRICT=true)
|
||||
AS_ARGS=(-DAT_JWT=true)
|
||||
fi
|
||||
|
||||
# The authorization server must be first and must be READY before the others: `edge` is an
|
||||
# OAuth2 client, and a client whose provider is configured with issuer-uri fetches the
|
||||
# discovery document during context refresh. See docs/01-the-four-processes.md.
|
||||
start authserver.AuthServerApplication 9000 /oauth2/jwks "${AS_ARGS[@]}"
|
||||
start downstream.DownstreamApplication 8082 /orders "${STRICT_ARGS[@]}"
|
||||
start edge.EdgeApplication 8081 /edge/naive
|
||||
start gateway.GatewayApplication 8080 /edge/naive
|
||||
echo "all four up"
|
||||
23
service-to-service/scripts/stop.sh
Executable file
23
service-to-service/scripts/stop.sh
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stop every process in the module.
|
||||
#
|
||||
# Two rules, both learned the hard way while building this repository:
|
||||
#
|
||||
# 1. Match the MAIN CLASS, never 'spring-boot' or 'java'. `pkill -f spring-boot` also matches
|
||||
# the shell command line that launched the application, so it kills your own shell.
|
||||
#
|
||||
# 2. Restrict the match to processes that are actually a JVM, and exclude this script and its
|
||||
# parent. A bracketed pattern like '[A]uthServerApplication' stops the pattern matching the
|
||||
# grep itself - but it does NOT stop it matching an ancestor shell whose command line
|
||||
# happens to contain that string, which is exactly what happens when you paste a here-doc
|
||||
# containing the class name into a terminal and then run this script from it. The victim
|
||||
# process dies with exit 137 and no output, which is a memorable afternoon.
|
||||
set -eu
|
||||
SELF=$$
|
||||
PARENT=$PPID
|
||||
ps -eo pid,ppid,comm,args | awk -v self="$SELF" -v parent="$PARENT" '
|
||||
$1 != self && $1 != parent && $3 ~ /^java/ && $0 ~ /com\.ankurm\.s2s\./ { print $1 }
|
||||
' | while read -r pid; do
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
done
|
||||
sleep 1
|
||||
48
service-to-service/scripts/user-token.sh
Executable file
48
service-to-service/scripts/user-token.sh
Executable file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# Drive a complete authorization_code + PKCE flow with curl and print the access token.
|
||||
#
|
||||
# TOKEN=$(./scripts/user-token.sh)
|
||||
#
|
||||
# There is no browser here and none is needed: the "browser flow" is four HTTP requests and a
|
||||
# cookie jar. Doing it by hand once is the fastest way to understand what your SPA's OIDC
|
||||
# library is actually doing, and it makes every transcript in docs/output/ reproducible.
|
||||
set -eu
|
||||
AS=http://127.0.0.1:9000
|
||||
JAR=$(mktemp)
|
||||
trap 'rm -f "$JAR"' EXIT
|
||||
|
||||
# 1. PKCE: a random verifier, and its base64url-encoded SHA-256 as the challenge.
|
||||
VERIFIER=$(head -c 48 /dev/urandom | base64 | tr -d '=+/' | cut -c1-64)
|
||||
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -binary -sha256 | base64 | tr '+/' '-_' | tr -d '=')
|
||||
|
||||
AUTHORIZE="$AS/oauth2/authorize?response_type=code&client_id=spa-client\
|
||||
&redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Fauthorized&scope=orders.read%20orders.write\
|
||||
&code_challenge=$CHALLENGE&code_challenge_method=S256"
|
||||
|
||||
# 2. Ask for the code. Unauthenticated, so this saves the request and redirects to /login.
|
||||
curl -s -o /dev/null -c "$JAR" -b "$JAR" "$AUTHORIZE"
|
||||
|
||||
# 3. Log in. The login page is CSRF-protected, so read the token out of the form.
|
||||
CSRF=$(curl -s -c "$JAR" -b "$JAR" "$AS/login" \
|
||||
| grep -oiE 'name="_csrf"[^>]*value="[^"]*"' | head -1 | sed 's/.*value="//; s/"//')
|
||||
curl -s -o /dev/null -c "$JAR" -b "$JAR" -X POST "$AS/login" \
|
||||
-d "username=alice" -d "password=password" -d "_csrf=$CSRF"
|
||||
|
||||
# 4. Follow the saved request. Now authenticated, so this redirects to the redirect_uri
|
||||
# carrying ?code=... We never let curl follow it; we just read the Location header.
|
||||
CODE=$(curl -s -o /dev/null -D- -c "$JAR" -b "$JAR" "$AUTHORIZE" \
|
||||
| grep -i '^location:' | sed 's/.*code=//; s/[&\r].*//')
|
||||
|
||||
if [ -z "$CODE" ]; then
|
||||
echo "no authorization code was issued - is the auth server up?" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Redeem it. A public client, so no client secret: the code_verifier is the proof.
|
||||
curl -s -X POST "$AS/oauth2/token" \
|
||||
-d grant_type=authorization_code \
|
||||
-d "code=$CODE" \
|
||||
-d "redirect_uri=http://127.0.0.1:8080/authorized" \
|
||||
-d client_id=spa-client \
|
||||
-d "code_verifier=$VERIFIER" \
|
||||
| python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])'
|
||||
@@ -0,0 +1,205 @@
|
||||
package com.ankurm.s2s.authserver;
|
||||
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.interfaces.RSAPrivateKey;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
|
||||
import com.nimbusds.jose.jwk.JWKSet;
|
||||
import com.nimbusds.jose.jwk.RSAKey;
|
||||
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
|
||||
import com.nimbusds.jose.jwk.source.JWKSource;
|
||||
import com.nimbusds.jose.proc.SecurityContext;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
|
||||
import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
|
||||
import org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization.OAuth2AuthorizationServerConfigurer;
|
||||
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
|
||||
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
|
||||
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
|
||||
import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat;
|
||||
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
|
||||
import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
|
||||
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
|
||||
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
/**
|
||||
* A real Spring Authorization Server, on port 9000. Everything downstream in this module is
|
||||
* validated against tokens this process minted, so nothing in the article is a hand-written JWT.
|
||||
*
|
||||
* <p>Three clients are registered, one per pattern the article covers:
|
||||
* <ul>
|
||||
* <li>{@code spa-client} — authorization_code + PKCE. This is where the <em>user's</em>
|
||||
* token comes from. {@code scripts/user-token.sh} drives the whole browser flow with curl.</li>
|
||||
* <li>{@code edge-service} — client_credentials. The edge service's own identity, with no
|
||||
* user anywhere in it.</li>
|
||||
* <li>{@code edge-exchange} — urn:ietf:params:oauth:grant-type:token-exchange (RFC 8693),
|
||||
* for trading the user's token for one scoped to the downstream service.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Tokens carry an {@code aud} claim naming {@code downstream-api}. Chapter 4 is about what
|
||||
* happens to that claim by default, which is nothing.
|
||||
*
|
||||
* <p>See <a href="../../../../../docs/01-the-four-processes.md">docs/01-the-four-processes.md</a>.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class AuthServerApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
System.setProperty("spring.config.name", "authserver");
|
||||
SpringApplication.run(AuthServerApplication.class, args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Note what this is <em>not</em>. Every Spring Authorization Server tutorial written
|
||||
* before 7.0 starts with
|
||||
* {@code OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http)}. That class,
|
||||
* and the whole
|
||||
* {@code org.springframework.security.oauth2.server.authorization.config.annotation.web.*}
|
||||
* package tree, is <b>absent</b> from {@code spring-security-oauth2-authorization-server}
|
||||
* 7.1.1. The configurer moved into {@code spring-security-config} at
|
||||
* {@code org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization},
|
||||
* and the entry point is now the DSL method {@code HttpSecurity.oauth2AuthorizationServer(..)}.
|
||||
* The old call is a compile error, not a deprecation.
|
||||
*/
|
||||
@Bean
|
||||
@Order(1)
|
||||
SecurityFilterChain authorizationServerChain(HttpSecurity http) throws Exception {
|
||||
OAuth2AuthorizationServerConfigurer configurer = new OAuth2AuthorizationServerConfigurer();
|
||||
return http
|
||||
.securityMatcher(configurer.getEndpointsMatcher())
|
||||
.with(configurer, (server) -> server.oidc(Customizer.withDefaults()))
|
||||
.authorizeHttpRequests((auth) -> auth.anyRequest().authenticated())
|
||||
.csrf((csrf) -> csrf.ignoringRequestMatchers(configurer.getEndpointsMatcher()))
|
||||
.exceptionHandling((exceptions) -> exceptions
|
||||
.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")))
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Order(2)
|
||||
SecurityFilterChain loginChain(HttpSecurity http) throws Exception {
|
||||
return http
|
||||
.authorizeHttpRequests((auth) -> auth.anyRequest().authenticated())
|
||||
.formLogin(Customizer.withDefaults())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
UserDetailsService users() {
|
||||
return new InMemoryUserDetailsManager(
|
||||
User.withUsername("alice").password("{noop}password").roles("USER").build());
|
||||
}
|
||||
|
||||
@Bean
|
||||
RegisteredClientRepository registeredClients() {
|
||||
TokenSettings tokens = TokenSettings.builder()
|
||||
.accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED)
|
||||
.accessTokenTimeToLive(Duration.ofMinutes(10))
|
||||
.build();
|
||||
|
||||
RegisteredClient spa = RegisteredClient.withId(UUID.randomUUID().toString())
|
||||
.clientId("spa-client")
|
||||
.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
|
||||
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.redirectUri("http://127.0.0.1:8080/authorized")
|
||||
.scope("orders.read")
|
||||
.scope("orders.write")
|
||||
.tokenSettings(tokens)
|
||||
// Explicit, because the default is not what the property name suggests - see
|
||||
// docs/01-the-four-processes.md. Without this the authorization_code flow stops
|
||||
// at a consent page even for a client that never asked for consent.
|
||||
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(false).build())
|
||||
.build();
|
||||
|
||||
RegisteredClient edge = RegisteredClient.withId(UUID.randomUUID().toString())
|
||||
.clientId("edge-service")
|
||||
.clientSecret("{noop}edge-secret")
|
||||
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
|
||||
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
|
||||
.scope("orders.read")
|
||||
.tokenSettings(tokens)
|
||||
.build();
|
||||
|
||||
RegisteredClient exchange = RegisteredClient.withId(UUID.randomUUID().toString())
|
||||
.clientId("edge-exchange")
|
||||
.clientSecret("{noop}exchange-secret")
|
||||
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
|
||||
.authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE)
|
||||
.scope("orders.read")
|
||||
.tokenSettings(tokens)
|
||||
.build();
|
||||
|
||||
// A client that has nothing to do with the downstream service. Its tokens carry
|
||||
// aud: reporting-api. Chapter 4 sends one of them to the downstream service and
|
||||
// watches it be accepted.
|
||||
RegisteredClient reporting = RegisteredClient.withId(UUID.randomUUID().toString())
|
||||
.clientId("reporting-service")
|
||||
.clientSecret("{noop}reporting-secret")
|
||||
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
|
||||
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
|
||||
.scope("orders.read")
|
||||
.tokenSettings(tokens)
|
||||
.build();
|
||||
|
||||
return new InMemoryRegisteredClientRepository(spa, edge, exchange, reporting);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds {@code aud: downstream-api} to every access token, so chapter 4 has something to not
|
||||
* validate.
|
||||
*/
|
||||
@Bean
|
||||
OAuth2TokenCustomizer<JwtEncodingContext> audienceCustomizer() {
|
||||
return (context) -> {
|
||||
if (!"access_token".equals(context.getTokenType().getValue())) {
|
||||
return;
|
||||
}
|
||||
String clientId = context.getRegisteredClient().getClientId();
|
||||
// Every token names the service it was minted for. Whether the receiving service
|
||||
// looks at that claim is chapter 4, and the answer by default is no.
|
||||
String audience = "reporting-service".equals(clientId) ? "reporting-api" : "downstream-api";
|
||||
context.getClaims().audience(java.util.List.of(audience));
|
||||
// RFC 9068 says an access token should be typed `at+jwt` and carry `client_id`.
|
||||
// Spring Authorization Server emits neither by default, and
|
||||
// JwtValidators.createAtJwtValidator() requires both. Behind a flag, because
|
||||
// turning it on breaks a stock NimbusJwtDecoder - see
|
||||
// docs/04-what-is-not-validated.md.
|
||||
if (Boolean.getBoolean("AT_JWT")) {
|
||||
context.getJwsHeader().type("at+jwt");
|
||||
context.getClaims().claim("client_id", clientId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Bean
|
||||
JWKSource<SecurityContext> jwkSource() throws Exception {
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||
generator.initialize(2048);
|
||||
KeyPair keyPair = generator.generateKeyPair();
|
||||
RSAKey key = new RSAKey.Builder((RSAPublicKey) keyPair.getPublic())
|
||||
.privateKey((RSAPrivateKey) keyPair.getPrivate())
|
||||
.keyID(UUID.randomUUID().toString())
|
||||
.build();
|
||||
return new ImmutableJWKSet<>(new JWKSet(key));
|
||||
}
|
||||
|
||||
@Bean
|
||||
AuthorizationServerSettings authorizationServerSettings() {
|
||||
return AuthorizationServerSettings.builder().issuer("http://127.0.0.1:9000").build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.ankurm.s2s.downstream;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.nimbusds.jose.JOSEObjectType;
|
||||
import com.nimbusds.jose.proc.DefaultJOSEObjectTypeVerifier;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtValidators;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* The service at the end of the chain, on port 8082. It is a plain resource server, and its job
|
||||
* in this repository is to report <em>who it thinks is calling</em>.
|
||||
*
|
||||
* <p>{@code /orders} echoes back the {@code sub}, {@code aud}, {@code scope} and {@code client_id}
|
||||
* of the token that reached it. That is the whole demonstration: relay a user's token and
|
||||
* {@code sub} is {@code alice}; use client credentials and {@code sub} is {@code edge-service}
|
||||
* and the user has disappeared from the system of record.
|
||||
*
|
||||
* <p>Run with {@code -DSTRICT=true} to install the audience and issuer validation that the
|
||||
* defaults leave out — see
|
||||
* <a href="../../../../../docs/04-what-is-not-validated.md">docs/04-what-is-not-validated.md</a>.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
@RestController
|
||||
public class DownstreamApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
System.setProperty("spring.config.name", "downstream");
|
||||
SpringApplication.run(DownstreamApplication.class, args);
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain chain(HttpSecurity http) throws Exception {
|
||||
return http
|
||||
.authorizeHttpRequests((auth) -> auth
|
||||
.requestMatchers("/orders/**").hasAuthority("SCOPE_orders.read")
|
||||
.anyRequest().authenticated())
|
||||
.oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()))
|
||||
.csrf((csrf) -> csrf.disable())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* The decoder. Which validators it carries is the entire subject of chapter 4.
|
||||
*
|
||||
* <p>{@code JwtValidators.createDefault()} is a {@code DelegatingOAuth2TokenValidator} over
|
||||
* three things: {@code JwtTypeValidator}, {@code JwtTimestampValidator} and
|
||||
* {@code X509CertificateThumbprintValidator}. No issuer. No audience. Boot's
|
||||
* auto-configuration adds an issuer validator when you set {@code issuer-uri}; nothing adds
|
||||
* an audience validator unless you do.
|
||||
*/
|
||||
@Bean
|
||||
JwtDecoder jwtDecoder() {
|
||||
boolean strict = Boolean.getBoolean("STRICT");
|
||||
NimbusJwtDecoder decoder = NimbusJwtDecoder
|
||||
.withJwkSetUri("http://127.0.0.1:9000/oauth2/jwks")
|
||||
// NimbusJwtDecoder's default JOSE type verifier accepts `JWT` and an absent
|
||||
// `typ`, and nothing else. An RFC 9068-compliant authorization server types its
|
||||
// access tokens `at+jwt`, and against the default decoder every one of them is
|
||||
// rejected with "the given typ value needs to be one of [JWT]" - a message that
|
||||
// says nothing about RFC 9068 and reads like a corrupt token.
|
||||
.jwtProcessorCustomizer((processor) -> processor.setJWSTypeVerifier(
|
||||
new DefaultJOSEObjectTypeVerifier<>(new JOSEObjectType("at+jwt"),
|
||||
JOSEObjectType.JWT, null)))
|
||||
.build();
|
||||
OAuth2TokenValidator<Jwt> validators = strict
|
||||
// createAtJwtValidator() enforces the RFC 9068 profile: typ, exp, sub, iat,
|
||||
// jti, iss, aud and client_id must all be present, on top of the issuer and
|
||||
// audience values named here. A token from an authorization server that does
|
||||
// not emit client_id fails this even when the issuer and audience are right.
|
||||
? JwtValidators.createAtJwtValidator()
|
||||
.issuer("http://127.0.0.1:9000")
|
||||
.audience("downstream-api")
|
||||
.build()
|
||||
// The default: a DelegatingOAuth2TokenValidator over three validators, read
|
||||
// back by reflection in ValidatorContractTests - JwtTypeValidator,
|
||||
// JwtTimestampValidator and X509CertificateThumbprintValidator. Note what is
|
||||
// NOT in that list. No issuer. No audience. No scope. A structurally valid,
|
||||
// unexpired token minted by this issuer for a completely different service
|
||||
// passes, which is docs/output/03-audience-ignored.txt.
|
||||
: JwtValidators.createDefault();
|
||||
decoder.setJwtValidator(validators);
|
||||
return decoder;
|
||||
}
|
||||
|
||||
@GetMapping("/orders")
|
||||
Map<String, Object> orders(Authentication authentication) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("service", "downstream:8082");
|
||||
body.put("strictValidation", Boolean.getBoolean("STRICT"));
|
||||
if (authentication instanceof JwtAuthenticationToken token) {
|
||||
Jwt jwt = token.getToken();
|
||||
body.put("sub", jwt.getSubject());
|
||||
body.put("aud", jwt.getAudience());
|
||||
body.put("iss", String.valueOf(jwt.getIssuer()));
|
||||
body.put("scope", jwt.getClaimAsString("scope"));
|
||||
body.put("client_id", jwt.getClaimAsString("client_id"));
|
||||
body.put("authorities", token.getAuthorities().stream().map(Object::toString).toList());
|
||||
body.put("cnf", jwt.getClaim("cnf"));
|
||||
}
|
||||
body.put("orders", List.of(Map.of("id", 1, "total", "42.00")));
|
||||
return body;
|
||||
}
|
||||
|
||||
@GetMapping("/orders/whoami")
|
||||
Map<String, Object> whoami(Authentication authentication) {
|
||||
return Map.of("principal", authentication.getName(), "type",
|
||||
authentication.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.ankurm.s2s.edge;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
|
||||
import org.springframework.security.oauth2.client.TokenExchangeOAuth2AuthorizedClientProvider;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
|
||||
|
||||
/**
|
||||
* The {@code OAuth2AuthorizedClientManager} the interceptor asks for tokens.
|
||||
*
|
||||
* <p>Two details matter and both are easy to get wrong.
|
||||
*
|
||||
* <p><b>Which manager.</b> {@code DefaultOAuth2AuthorizedClientManager} is request-scoped and
|
||||
* needs an {@code HttpServletRequest}; it is for a browser-facing client. For service-to-service
|
||||
* calls, where there is no end user whose consent is being managed, the right one is
|
||||
* {@code AuthorizedClientServiceOAuth2AuthorizedClientManager}, which stores authorized clients
|
||||
* in an {@code OAuth2AuthorizedClientService} and works on any thread.
|
||||
*
|
||||
* <p><b>Which providers.</b> The builder's defaults do not include token exchange, so
|
||||
* {@code TokenExchangeOAuth2AuthorizedClientProvider} has to be added explicitly. Leave it out
|
||||
* and the {@code edge-exchange} registration silently produces no token — the manager
|
||||
* returns {@code null} rather than raising anything.
|
||||
*/
|
||||
@Configuration
|
||||
public class ClientManagerConfig {
|
||||
|
||||
@Bean
|
||||
OAuth2AuthorizedClientManager authorizedClientManager(ClientRegistrationRepository registrations,
|
||||
OAuth2AuthorizedClientService clientService) {
|
||||
|
||||
var provider = OAuth2AuthorizedClientProviderBuilder.builder()
|
||||
.clientCredentials()
|
||||
.refreshToken()
|
||||
.provider(new TokenExchangeOAuth2AuthorizedClientProvider())
|
||||
.build();
|
||||
|
||||
var manager = new AuthorizedClientServiceOAuth2AuthorizedClientManager(registrations, clientService);
|
||||
manager.setAuthorizedClientProvider(provider);
|
||||
return manager;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.ankurm.s2s.edge;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
|
||||
import org.springframework.security.oauth2.client.web.client.OAuth2ClientHttpRequestInterceptor;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import static org.springframework.security.oauth2.client.web.client.RequestAttributeClientRegistrationIdResolver.clientRegistrationId;
|
||||
|
||||
/**
|
||||
* Four {@link RestClient} instances, differing only in what they put in the
|
||||
* {@code Authorization} header. Everything else — base URL, path, error handling —
|
||||
* is identical, so the transcripts differ in exactly one variable.
|
||||
*
|
||||
* <p>See <a href="../../../../../docs/03-restclient-interceptors.md">docs/03-restclient-interceptors.md</a>.
|
||||
*/
|
||||
@Component
|
||||
public class DownstreamClients {
|
||||
|
||||
private static final String DOWNSTREAM = "http://127.0.0.1:8082";
|
||||
|
||||
private final RestClient plain;
|
||||
|
||||
private final RestClient relay;
|
||||
|
||||
private final RestClient managed;
|
||||
|
||||
public DownstreamClients(RestClient.Builder builder,
|
||||
OAuth2AuthorizedClientManager authorizedClientManager) {
|
||||
|
||||
this.plain = builder.clone().baseUrl(DOWNSTREAM).build();
|
||||
|
||||
// Hand-rolled relay. Ten lines, no dependency on the OAuth2 client machinery, and it
|
||||
// forwards the caller's identity unchanged - which is exactly what you want for an
|
||||
// internal hop and exactly what you do NOT want for a call that leaves your trust
|
||||
// boundary. The token's audience and scope were minted for the original request.
|
||||
this.relay = builder.clone()
|
||||
.baseUrl(DOWNSTREAM)
|
||||
.requestInterceptor((request, body, execution) -> {
|
||||
var authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication instanceof JwtAuthenticationToken token) {
|
||||
request.getHeaders().setBearerAuth(token.getToken().getTokenValue());
|
||||
}
|
||||
return execution.execute(request, body);
|
||||
})
|
||||
.build();
|
||||
|
||||
// The framework's interceptor. It resolves a ClientRegistration id from a request
|
||||
// attribute, asks the OAuth2AuthorizedClientManager for a token under that
|
||||
// registration, caches it, and refreshes it when it expires. Which registration is
|
||||
// chosen per call, via clientRegistrationId(..) below.
|
||||
this.managed = builder.clone()
|
||||
.baseUrl(DOWNSTREAM)
|
||||
.requestInterceptor(new OAuth2ClientHttpRequestInterceptor(authorizedClientManager))
|
||||
.build();
|
||||
}
|
||||
|
||||
public Object plain() {
|
||||
return get(this.plain, null);
|
||||
}
|
||||
|
||||
public Object relayed() {
|
||||
return get(this.relay, null);
|
||||
}
|
||||
|
||||
public Object clientCredentials() {
|
||||
return get(this.managed, "edge-service");
|
||||
}
|
||||
|
||||
public Object exchanged() {
|
||||
return get(this.managed, "edge-exchange");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Object get(RestClient client, String registrationId) {
|
||||
var request = client.get().uri("/orders");
|
||||
if (registrationId != null) {
|
||||
request = request.attributes(clientRegistrationId(registrationId));
|
||||
}
|
||||
return request.header(HttpHeaders.ACCEPT, "application/json")
|
||||
.retrieve()
|
||||
.body(Map.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.ankurm.s2s.edge;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
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.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* The middle service, on port 8081. It is simultaneously a resource server (it validates the
|
||||
* token that arrives) and an OAuth2 client (it needs a token to call
|
||||
* {@code downstream}). Every strategy for producing that second token is an endpoint here, so
|
||||
* the difference between them is a diff of two JSON bodies rather than an argument.
|
||||
*
|
||||
* <table>
|
||||
* <caption>Endpoints</caption>
|
||||
* <tr><th>Endpoint</th><th>Strategy</th></tr>
|
||||
* <tr><td>{@code /edge/naive}</td><td>No propagation at all — the control</td></tr>
|
||||
* <tr><td>{@code /edge/relay}</td><td>Relay the incoming bearer token</td></tr>
|
||||
* <tr><td>{@code /edge/client-credentials}</td><td>The service's own token</td></tr>
|
||||
* <tr><td>{@code /edge/exchange}</td><td>RFC 8693 token exchange</td></tr>
|
||||
* <tr><td>{@code /edge/relay-async}</td><td>Relay from a different thread — the trap</td></tr>
|
||||
* </table>
|
||||
*
|
||||
* <p>See <a href="../../../../../docs/02-three-ways-to-get-a-token.md">docs/02-three-ways-to-get-a-token.md</a>.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
@RestController
|
||||
public class EdgeApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
System.setProperty("spring.config.name", "edge");
|
||||
SpringApplication.run(EdgeApplication.class, args);
|
||||
}
|
||||
|
||||
private final DownstreamClients clients;
|
||||
|
||||
public EdgeApplication(DownstreamClients clients) {
|
||||
this.clients = clients;
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain chain(HttpSecurity http) throws Exception {
|
||||
return http
|
||||
.authorizeHttpRequests((auth) -> auth.anyRequest().authenticated())
|
||||
.oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()))
|
||||
.csrf((csrf) -> csrf.disable())
|
||||
.build();
|
||||
}
|
||||
|
||||
@GetMapping("/edge/naive")
|
||||
Map<String, Object> naive() {
|
||||
return wrap("no token forwarded", this.clients::plain);
|
||||
}
|
||||
|
||||
@GetMapping("/edge/relay")
|
||||
Map<String, Object> relay() {
|
||||
return wrap("bearer token relayed from the incoming request", this.clients::relayed);
|
||||
}
|
||||
|
||||
@GetMapping("/edge/client-credentials")
|
||||
Map<String, Object> clientCredentials() {
|
||||
return wrap("the edge service's own client_credentials token", this.clients::clientCredentials);
|
||||
}
|
||||
|
||||
@GetMapping("/edge/exchange")
|
||||
Map<String, Object> exchange() {
|
||||
return wrap("RFC 8693 token exchange", this.clients::exchanged);
|
||||
}
|
||||
|
||||
/**
|
||||
* The trap. The relay interceptor reads the token from {@code SecurityContextHolder}, which
|
||||
* is a {@code ThreadLocal}. Make the downstream call on a thread the request did not create
|
||||
* and there is nothing there to read.
|
||||
*/
|
||||
@GetMapping("/edge/relay-async")
|
||||
Map<String, Object> relayAsync() {
|
||||
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
|
||||
return wrap("relay attempted from a separate thread",
|
||||
() -> executor.submit(this.clients::relayed).get());
|
||||
}
|
||||
catch (Exception ex) {
|
||||
return Map.of("strategy", "relay attempted from a separate thread",
|
||||
"error", unwrap(ex));
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> wrap(String strategy, Callable<Object> call) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("strategy", strategy);
|
||||
try {
|
||||
out.put("downstream", call.call());
|
||||
}
|
||||
catch (Exception ex) {
|
||||
out.put("error", unwrap(ex));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String unwrap(Throwable throwable) {
|
||||
Throwable root = throwable;
|
||||
while (root.getCause() != null) {
|
||||
root = root.getCause();
|
||||
}
|
||||
return root.getClass().getSimpleName() + ": " + root.getMessage();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.ankurm.s2s.gateway;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* Spring Cloud Gateway Server MVC, on port 8080, configured entirely in
|
||||
* {@code src/main/resources/gateway.yml}.
|
||||
*
|
||||
* <p>The point of interest is the {@code TokenRelay} filter, which Spring Cloud provides as
|
||||
* {@code TokenRelayFilterFunctions.tokenRelay()} and
|
||||
* {@code TokenRelayFilterFunctions.tokenRelay(String clientRegistrationId)}. It takes the access
|
||||
* token of the currently authenticated user — or of the named client registration —
|
||||
* and puts it in the {@code Authorization} header of the proxied request.
|
||||
*
|
||||
* <p>Read <a href="../../../../../docs/05-the-gateway.md">docs/05-the-gateway.md</a> before
|
||||
* copying any of this: Spring Cloud is a separate release train with its own Boot baseline, and
|
||||
* the gateway's own {@code TokenRelay} does something narrower than the name suggests.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class GatewayApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
System.setProperty("spring.config.name", "gateway");
|
||||
SpringApplication.run(GatewayApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.ankurm.s2s.gateway;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
/**
|
||||
* The gateway does not authenticate anything. It proxies.
|
||||
*
|
||||
* <p>This bean exists because without it Boot applies its default chain — HTTP Basic and
|
||||
* form login over every path — and a caller presenting a perfectly good
|
||||
* {@code Authorization: Bearer} header receives {@code 401 WWW-Authenticate: Basic} from the
|
||||
* gateway, having never reached anything. The token was fine. The gateway had never been told
|
||||
* what to do with it.
|
||||
*
|
||||
* <p>A real gateway is usually the opposite of this: it terminates the user session with
|
||||
* {@code oauth2Login()} and issues nothing downstream but a token it obtained itself. That
|
||||
* configuration is what Spring Cloud's {@code TokenRelay} filter is written for — see
|
||||
* <a href="../../../../../docs/05-the-gateway.md">docs/05-the-gateway.md</a>.
|
||||
*/
|
||||
@Configuration
|
||||
public class GatewaySecurityConfig {
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain chain(HttpSecurity http) throws Exception {
|
||||
return http
|
||||
.authorizeHttpRequests((auth) -> auth.anyRequest().permitAll())
|
||||
.csrf((csrf) -> csrf.disable())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.ankurm.s2s.mtls;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* A resource server that authenticates callers by client certificate rather than by token, on
|
||||
* port 8443. It exists so the article's mTLS section is a transcript rather than a diagram.
|
||||
*
|
||||
* <p>Run {@code ./scripts/certs.sh} first. Then:
|
||||
*
|
||||
* <pre>
|
||||
* curl --cacert target/certs/internal-ca.crt \
|
||||
* --cert target/certs/edge.crt --key target/certs/edge.key \
|
||||
* https://localhost:8443/mtls/whoami
|
||||
* </pre>
|
||||
*
|
||||
* <p>{@code rogue.crt} carries an identical subject and a different issuer, and the TLS
|
||||
* handshake — not Spring Security — is what rejects it.
|
||||
*
|
||||
* <p>{@code /mtls/trusted-header} is the counterpart: an endpoint that believes an
|
||||
* {@code X-Client-Cert-Subject} header, the way an application behind a service mesh usually
|
||||
* does. See <a href="../../../../../docs/06-mtls.md">docs/06-mtls.md</a>.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
@RestController
|
||||
public class MtlsApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
System.setProperty("spring.config.name", "mtls");
|
||||
SpringApplication.run(MtlsApplication.class, args);
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain chain(HttpSecurity http) throws Exception {
|
||||
return http
|
||||
.authorizeHttpRequests((auth) -> auth
|
||||
.requestMatchers("/mtls/trusted-header").permitAll()
|
||||
.anyRequest().authenticated())
|
||||
// x509() maps the certificate's subject to a principal name and looks it up in a
|
||||
// UserDetailsService. The certificate was already verified against the trust store
|
||||
// by the TLS layer before any of this ran; Spring Security is deciding what the
|
||||
// verified identity is allowed to do, not whether the certificate is genuine.
|
||||
.x509((x509) -> x509
|
||||
.subjectPrincipalRegex("CN=([^,]*)(?:,|$)")
|
||||
.userDetailsService(certificateUsers()))
|
||||
.csrf((csrf) -> csrf.disable())
|
||||
.build();
|
||||
}
|
||||
|
||||
UserDetailsService certificateUsers() {
|
||||
return (username) -> User.withUsername(username).password("{noop}unused").roles("SERVICE").build();
|
||||
}
|
||||
|
||||
@GetMapping("/mtls/whoami")
|
||||
Map<String, Object> whoami(Authentication authentication, HttpServletRequest request) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("principal", authentication.getName());
|
||||
out.put("authenticationType", authentication.getClass().getSimpleName());
|
||||
out.put("authorities", authentication.getAuthorities().stream().map(Object::toString).toList());
|
||||
var chain = (java.security.cert.X509Certificate[]) request
|
||||
.getAttribute("jakarta.servlet.request.X509Certificate");
|
||||
if (chain != null && chain.length > 0) {
|
||||
out.put("certificateSubject", chain[0].getSubjectX500Principal().getName());
|
||||
out.put("certificateIssuer", chain[0].getIssuerX500Principal().getName());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* What an application inside a mesh usually does: trust a header the sidecar is supposed to
|
||||
* have set. Nothing here verifies that the header came from the sidecar. If anything can
|
||||
* reach this port without going through the proxy — another pod, a debug
|
||||
* port-forward, a misconfigured NetworkPolicy — it can name itself whatever it likes.
|
||||
*/
|
||||
@GetMapping("/mtls/trusted-header")
|
||||
Map<String, Object> trustedHeader(HttpServletRequest request) {
|
||||
String claimed = request.getHeader("X-Client-Cert-Subject");
|
||||
return Map.of("caller", (claimed != null) ? claimed : "(header absent)",
|
||||
"verifiedBy", "nothing. This endpoint believes a header.");
|
||||
}
|
||||
}
|
||||
8
service-to-service/src/main/resources/authserver.yml
Normal file
8
service-to-service/src/main/resources/authserver.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
server:
|
||||
port: 9000
|
||||
spring:
|
||||
application:
|
||||
name: authserver
|
||||
logging:
|
||||
level:
|
||||
org.springframework.security.oauth2.server.authorization: ${AS_LOG_LEVEL:INFO}
|
||||
17
service-to-service/src/main/resources/downstream.yml
Normal file
17
service-to-service/src/main/resources/downstream.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
server:
|
||||
port: 8082
|
||||
spring:
|
||||
application:
|
||||
name: downstream
|
||||
security:
|
||||
oauth2:
|
||||
resourceserver:
|
||||
jwt:
|
||||
# Deliberately jwk-set-uri and not issuer-uri. With issuer-uri, Boot builds the
|
||||
# decoder from the discovery document and installs a JwtIssuerValidator; the bean in
|
||||
# DownstreamApplication overrides that so the article can show the two validator sets
|
||||
# side by side. Chapter 4.
|
||||
jwk-set-uri: http://127.0.0.1:9000/oauth2/jwks
|
||||
logging:
|
||||
level:
|
||||
org.springframework.security.oauth2.server.resource: ${RS_LOG_LEVEL:INFO}
|
||||
39
service-to-service/src/main/resources/edge.yml
Normal file
39
service-to-service/src/main/resources/edge.yml
Normal file
@@ -0,0 +1,39 @@
|
||||
server:
|
||||
port: 8081
|
||||
spring:
|
||||
application:
|
||||
name: edge
|
||||
security:
|
||||
oauth2:
|
||||
resourceserver:
|
||||
jwt:
|
||||
jwk-set-uri: http://127.0.0.1:9000/oauth2/jwks
|
||||
client:
|
||||
provider:
|
||||
local:
|
||||
# Deliberately NOT issuer-uri. `issuer-uri` makes ClientRegistrations fetch
|
||||
# /.well-known/openid-configuration during context refresh, so this service cannot
|
||||
# start unless the authorization server is already up - a hard startup-ordering
|
||||
# dependency between two deployments, with a stack trace ending in
|
||||
# `ResourceAccessException: I/O error on GET request for
|
||||
# ".../.well-known/openid-configuration": Connection refused` rather than
|
||||
# anything that mentions ordering. Naming the endpoints removes it.
|
||||
# See docs/01-the-four-processes.md.
|
||||
token-uri: http://127.0.0.1:9000/oauth2/token
|
||||
jwk-set-uri: http://127.0.0.1:9000/oauth2/jwks
|
||||
registration:
|
||||
edge-service:
|
||||
provider: local
|
||||
client-id: edge-service
|
||||
client-secret: edge-secret
|
||||
authorization-grant-type: client_credentials
|
||||
scope: orders.read
|
||||
edge-exchange:
|
||||
provider: local
|
||||
client-id: edge-exchange
|
||||
client-secret: exchange-secret
|
||||
authorization-grant-type: urn:ietf:params:oauth:grant-type:token-exchange
|
||||
scope: orders.read
|
||||
logging:
|
||||
level:
|
||||
org.springframework.security.oauth2.client: ${CLIENT_LOG_LEVEL:INFO}
|
||||
32
service-to-service/src/main/resources/gateway.yml
Normal file
32
service-to-service/src/main/resources/gateway.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
server:
|
||||
port: 8080
|
||||
spring:
|
||||
application:
|
||||
name: gateway
|
||||
cloud:
|
||||
gateway:
|
||||
server:
|
||||
webmvc:
|
||||
routes:
|
||||
# Two routes to the same place. The only difference is the TokenRelay filter,
|
||||
# which is the point: compare docs/output/04-gateway-token-relay.txt.
|
||||
- id: edge-with-relay
|
||||
uri: http://127.0.0.1:8081
|
||||
predicates:
|
||||
- Path=/edge/**
|
||||
filters:
|
||||
# TokenRelay with no argument forwards the CURRENTLY AUTHENTICATED USER's
|
||||
# access token - the one obtained by oauth2Login(). It does not forward an
|
||||
# incoming bearer token; the incoming header is simply proxied like any other.
|
||||
# Chapter 5.
|
||||
- TokenRelay=
|
||||
- id: edge-without-relay
|
||||
uri: http://127.0.0.1:8081
|
||||
predicates:
|
||||
- Path=/norelay/**
|
||||
filters:
|
||||
- StripPrefix=1
|
||||
- SetPath=/edge/relay
|
||||
logging:
|
||||
level:
|
||||
org.springframework.cloud.gateway: ${GATEWAY_LOG_LEVEL:INFO}
|
||||
26
service-to-service/src/main/resources/mtls.yml
Normal file
26
service-to-service/src/main/resources/mtls.yml
Normal file
@@ -0,0 +1,26 @@
|
||||
server:
|
||||
port: 8443
|
||||
ssl:
|
||||
bundle: server
|
||||
# NEED, not WANT. With `want`, a client that presents no certificate still completes the
|
||||
# handshake and arrives unauthenticated - which is what you want if some endpoints are
|
||||
# public, and a silent hole if you assumed the connector was doing the authorizing.
|
||||
client-auth: need
|
||||
spring:
|
||||
application:
|
||||
name: mtls
|
||||
ssl:
|
||||
bundle:
|
||||
pem:
|
||||
server:
|
||||
keystore:
|
||||
certificate: file:target/certs/server.crt
|
||||
private-key: file:target/certs/server.key
|
||||
truststore:
|
||||
# The list of CAs whose certificates this service will accept. This file is the
|
||||
# entire trust decision. rogue.crt has the same subject as edge.crt and a
|
||||
# different issuer, and this is the line that tells them apart.
|
||||
certificate: file:target/certs/internal-ca.crt
|
||||
logging:
|
||||
level:
|
||||
org.springframework.security.web.authentication.preauth.x509: ${X509_LOG_LEVEL:INFO}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.ankurm.s2s;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtValidators;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* What the JWT validators actually check. These assertions exist because the answer surprised
|
||||
* me while writing the companion repository, and because a future Spring Security release that
|
||||
* changes the default set should break a test rather than a paragraph.
|
||||
*
|
||||
* <p>The full end-to-end evidence is in {@code docs/output/03-audience-ignored.txt} and
|
||||
* {@code docs/output/05-strict-validation.txt}; these are the same facts pinned in-process.
|
||||
*/
|
||||
class ValidatorContractTests {
|
||||
|
||||
/** An access token typed the way RFC 9068 requires. */
|
||||
private static Jwt atJwt(Map<String, Object> claims) {
|
||||
Jwt.Builder builder = Jwt.withTokenValue("token")
|
||||
.header("alg", "RS256")
|
||||
.header("typ", "at+jwt")
|
||||
.issuedAt(Instant.now().minusSeconds(60))
|
||||
.expiresAt(Instant.now().plusSeconds(600))
|
||||
.subject("alice");
|
||||
claims.forEach(builder::claim);
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the at+jwt validator rejects a token typed JWT, with a message one word from the Nimbus one")
|
||||
void atJwtRejectsPlainJwtType() {
|
||||
OAuth2TokenValidator<Jwt> validator = JwtValidators.createAtJwtValidator()
|
||||
.issuer("http://127.0.0.1:9000")
|
||||
.audience("downstream-api")
|
||||
.build();
|
||||
Jwt plain = jwt(Map.of("aud", List.of("downstream-api"), "iss", "http://127.0.0.1:9000",
|
||||
"jti", "id", "client_id", "edge-service"));
|
||||
var result = validator.validate(plain);
|
||||
assertThat(result.hasErrors()).isTrue();
|
||||
// Spring's JwtTypeValidator: "the given typ value needs to be one of
|
||||
// [at+jwt, application/at+jwt]"
|
||||
// Nimbus's DefaultJOSEObjectTypeVerifier, one layer lower: "the given typ value needs
|
||||
// to be one of [JWT]"
|
||||
// Nearly the same sentence from two different components, meaning opposite things.
|
||||
assertThat(result.getErrors().iterator().next().getDescription())
|
||||
.contains("at+jwt", "application/at+jwt");
|
||||
}
|
||||
|
||||
private static Jwt jwt(Map<String, Object> claims) {
|
||||
Jwt.Builder builder = Jwt.withTokenValue("token")
|
||||
.header("alg", "RS256")
|
||||
.issuedAt(Instant.now().minusSeconds(60))
|
||||
.expiresAt(Instant.now().plusSeconds(600))
|
||||
.subject("alice");
|
||||
claims.forEach(builder::claim);
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the default validator set does not look at the audience")
|
||||
void defaultIgnoresAudience() {
|
||||
OAuth2TokenValidator<Jwt> validator = JwtValidators.createDefault();
|
||||
Jwt token = jwt(Map.of("aud", List.of("some-other-service"),
|
||||
"iss", "https://an-issuer-we-never-heard-of.example.com"));
|
||||
assertThat(validator.validate(token).hasErrors()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the default validator set does not look at the issuer either")
|
||||
void defaultIgnoresIssuer() {
|
||||
OAuth2TokenValidator<Jwt> validator = JwtValidators.createDefault();
|
||||
assertThat(validator.validate(jwt(Map.of("iss", "https://evil.example.com"))).hasErrors())
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the default validator set does reject an expired token")
|
||||
void defaultRejectsExpired() {
|
||||
OAuth2TokenValidator<Jwt> validator = JwtValidators.createDefault();
|
||||
Jwt expired = Jwt.withTokenValue("token")
|
||||
.header("alg", "RS256")
|
||||
.issuedAt(Instant.now().minusSeconds(7200))
|
||||
.expiresAt(Instant.now().minusSeconds(3600))
|
||||
.subject("alice")
|
||||
.build();
|
||||
assertThat(validator.validate(expired).hasErrors()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createDefault() delegates to three validators, none of which is about identity")
|
||||
void defaultDelegates() {
|
||||
OAuth2TokenValidator<Jwt> validator = JwtValidators.createDefault();
|
||||
assertThat(validator).isInstanceOf(DelegatingOAuth2TokenValidator.class);
|
||||
// Reading the delegate list back rather than trusting the disassembly.
|
||||
var field = org.springframework.util.ReflectionUtils
|
||||
.findField(DelegatingOAuth2TokenValidator.class, "tokenValidators");
|
||||
org.springframework.util.ReflectionUtils.makeAccessible(field);
|
||||
@SuppressWarnings("unchecked")
|
||||
var delegates = (java.util.Collection<OAuth2TokenValidator<Jwt>>) org.springframework.util.ReflectionUtils
|
||||
.getField(field, validator);
|
||||
// Reading this back is why the article says three rather than two: the disassembly
|
||||
// of createDefault() shows JwtTimestampValidator and X509CertificateThumbprintValidator
|
||||
// being constructed, and JwtTypeValidator arrives from createDefaultWithValidators.
|
||||
assertThat(delegates.stream().map((d) -> d.getClass().getSimpleName()))
|
||||
.containsExactlyInAnyOrder("JwtTypeValidator", "JwtTimestampValidator",
|
||||
"X509CertificateThumbprintValidator");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the at+jwt validator rejects a token whose audience names another service")
|
||||
void atJwtRejectsWrongAudience() {
|
||||
OAuth2TokenValidator<Jwt> validator = JwtValidators.createAtJwtValidator()
|
||||
.issuer("http://127.0.0.1:9000")
|
||||
.audience("downstream-api")
|
||||
.build();
|
||||
Jwt token = atJwt(Map.of("aud", List.of("reporting-api"), "iss", "http://127.0.0.1:9000",
|
||||
"jti", "id", "client_id", "reporting-service"));
|
||||
assertThat(validator.validate(token).hasErrors()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the at+jwt validator requires a client_id claim, which Spring Authorization Server does not emit by default")
|
||||
void atJwtRequiresClientId() {
|
||||
OAuth2TokenValidator<Jwt> validator = JwtValidators.createAtJwtValidator()
|
||||
.issuer("http://127.0.0.1:9000")
|
||||
.audience("downstream-api")
|
||||
.build();
|
||||
Jwt withoutClientId = jwt(Map.of("aud", List.of("downstream-api"),
|
||||
"iss", "http://127.0.0.1:9000", "jti", "id"));
|
||||
assertThat(validator.validate(withoutClientId).hasErrors()).isTrue();
|
||||
|
||||
Jwt withClientId = atJwt(Map.of("aud", List.of("downstream-api"),
|
||||
"iss", "http://127.0.0.1:9000", "jti", "id", "client_id", "edge-service"));
|
||||
assertThat(validator.validate(withClientId).hasErrors()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createDefaultWithIssuer adds an issuer check and still no audience check")
|
||||
void withIssuerStillIgnoresAudience() {
|
||||
OAuth2TokenValidator<Jwt> validator = JwtValidators
|
||||
.createDefaultWithIssuer("http://127.0.0.1:9000");
|
||||
Jwt wrongIssuer = jwt(Map.of("iss", "https://evil.example.com", "aud", List.of("x")));
|
||||
assertThat(validator.validate(wrongIssuer).hasErrors()).isTrue();
|
||||
Jwt rightIssuerWrongAudience = jwt(Map.of("iss", "http://127.0.0.1:9000",
|
||||
"aud", List.of("some-other-service")));
|
||||
assertThat(validator.validate(rightIssuerWrongAudience).hasErrors()).isFalse();
|
||||
}
|
||||
}
|
||||
87
ssrf/README.md
Normal file
87
ssrf/README.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# `ssrf` — SSRF mitigation with `InetAddressFilter`, vulnerable endpoint included
|
||||
|
||||
Companion project for
|
||||
[**HTTP Client SSRF Mitigation in Spring Boot 4.1: The `InetAddressFilter` Everyone Will
|
||||
Configure Backwards**](https://ankurm.com/spring-boot-4-1-ssrf-inetaddressfilter/) on ankurm.com.
|
||||
|
||||
One application, one deliberately vulnerable endpoint, and five filter configurations selected
|
||||
by Spring profile. Every transcript under [`docs/output/`](docs/output/) came from running it;
|
||||
`./scripts/run-all.sh` regenerates all of them.
|
||||
|
||||
## Versions
|
||||
|
||||
| | Version | Notes |
|
||||
|---|---|---|
|
||||
| JDK | 25 (Temurin 25.0.4.1+1) | current LTS |
|
||||
| Spring Boot | **4.1.1** | 4.1.0 GA was 10 June 2026; `InetAddressFilter` is `@since 4.1.0` |
|
||||
| Spring Framework | 7.0.9 | Boot-managed |
|
||||
| Apache HttpComponents | 5.6.4 | on the classpath deliberately — see [chapter 4](docs/04-where-the-filter-runs.md) |
|
||||
| Tomcat | 11.0.24 | |
|
||||
| JUnit Jupiter / AssertJ | Boot-managed | 4 assertions |
|
||||
|
||||
Versions were read from `repo1.maven.org/.../maven-metadata.xml`, not from release
|
||||
announcements.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
./scripts/run.sh # no filter bean at all
|
||||
./scripts/exploit.sh # four targets, four sets of credentials
|
||||
|
||||
./scripts/run.sh docsfilter # InetAddressFilter.externalAddresses()
|
||||
./scripts/exploit.sh # internal targets blocked, example.com still works
|
||||
|
||||
./scripts/run.sh blocklist # the inversion
|
||||
./scripts/exploit.sh # RFC 1918 target succeeds, example.com fails
|
||||
|
||||
./scripts/run-all.sh # regenerate everything under docs/output/
|
||||
```
|
||||
|
||||
## Profiles
|
||||
|
||||
| Profile | Filter bean | What it shows |
|
||||
|---|---|---|
|
||||
| *(none)* | — | the exploit, working |
|
||||
| `docsfilter` | `externalAddresses()` | the reference documentation's recommendation, and it is correct |
|
||||
| `blocklist` | `of(RFC1918)` | the release notes' word "block", acted on: attack succeeds, legitimate call fails |
|
||||
| `negated` | `internalAddresses().negate()` | looks equivalent to `externalAddresses()`, differs on four rows |
|
||||
| `allowlist` | `externalAddresses().and(of(...))` | naming your destinations, and what that costs when their DNS changes |
|
||||
| `twofilters` | two beans | the context does not start, and the diagnostic blames `RestClient` |
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|---|---|
|
||||
| `GET /preview?url=` | the vulnerable fetcher |
|
||||
| `GET /internal/credentials` | the thing that must not be reachable |
|
||||
| `GET /diag/filter?host=` | what the running context decided. Delete before shipping |
|
||||
|
||||
## Documentation
|
||||
|
||||
1. [What SSRF actually costs you](docs/01-what-ssrf-costs-you.md)
|
||||
2. [The exploit, start to finish](docs/02-the-exploit.md)
|
||||
3. [`matches` means allow](docs/03-allow-not-block.md) — the one that matters
|
||||
4. [Where the filter runs depends on your HTTP client](docs/04-where-the-filter-runs.md)
|
||||
5. [Wiring it up, and the three ways it silently does nothing](docs/05-wiring-it-up.md)
|
||||
6. [Operating it](docs/06-operating-it.md)
|
||||
7. [Composing filters, and the vararg that matches nothing](docs/07-composing-filters.md)
|
||||
|
||||
## Captured output
|
||||
|
||||
| File | Produced by |
|
||||
|---|---|
|
||||
| [`filter-matrix.txt`](docs/output/filter-matrix.txt) | `FilterMatrix` — 15 addresses × 9 filters |
|
||||
| [`exploit-by-profile.txt`](docs/output/exploit-by-profile.txt) | `run-all.sh` — five profiles, five targets each |
|
||||
| [`and-varargs-trap.txt`](docs/output/and-varargs-trap.txt) | `AndVarargsTrap` |
|
||||
| [`two-filter-beans.txt`](docs/output/two-filter-beans.txt) | the `twofilters` startup failure |
|
||||
| [`tests.txt`](docs/output/tests.txt) | `WhereTheFilterRunsTests` |
|
||||
|
||||
## The three findings worth carrying away
|
||||
|
||||
1. **`matches` means allow.** The release notes say "block"; the reference documentation says
|
||||
"only allow". The second is right. Writing `of(<ranges to forbid>)` produces a filter that
|
||||
permits exactly what you meant to stop.
|
||||
2. **`internalAddresses().negate()` is not `externalAddresses()`.** They disagree on CGNAT
|
||||
space, `0.0.0.0`, TEST-NET-1 and multicast — the negation allows all four.
|
||||
3. **`and("a", "b")` matches nothing.** Each address becomes a separate filter and they are
|
||||
ANDed. Wrap multiple addresses in `of(...)` first.
|
||||
46
ssrf/docs/01-what-ssrf-costs-you.md
Normal file
46
ssrf/docs/01-what-ssrf-costs-you.md
Normal file
@@ -0,0 +1,46 @@
|
||||
[Module README](../README.md) · [The exploit →](02-the-exploit.md)
|
||||
|
||||
# 1. What SSRF actually costs you
|
||||
|
||||
Server-Side Request Forgery is not a parsing bug. Every line of
|
||||
[`LinkPreviewController`](../src/main/java/com/ankurm/ssrf/LinkPreviewController.java) is
|
||||
correct in isolation. The vulnerability is architectural: a process that will fetch a URL of the
|
||||
caller's choosing sits inside a network where some destinations are privileged, and privilege in
|
||||
that network is decided by source address.
|
||||
|
||||
That is why SSRF is so consistently severe. The attacker does not need to reach your internal
|
||||
service — they need your service to reach it, and it already can.
|
||||
|
||||
The canonical prize is the cloud instance metadata service on `169.254.169.254`, which hands
|
||||
short-lived role credentials to anything on the instance that asks, with no authentication. But
|
||||
the ordinary case is duller and more common: an internal admin API, an unauthenticated actuator,
|
||||
a `/metrics` endpoint, an Elasticsearch cluster, a Redis instance, a sidecar's admin port.
|
||||
|
||||
## The features that are this bug
|
||||
|
||||
If your service does any of these with a user-supplied URL, you have this shape:
|
||||
|
||||
- link previews and URL unfurling
|
||||
- webhook registration and its "send a test event" button
|
||||
- avatar or document "import from URL"
|
||||
- server-side PDF and screenshot rendering
|
||||
- XML parsing with external entities enabled
|
||||
- anything that follows a redirect it did not choose
|
||||
|
||||
## What Boot 4.1 changed
|
||||
|
||||
Before 4.1 you wrote the defence yourself: resolve the host, check the address against your own
|
||||
list of forbidden ranges, and hope you did it in the same lookup the connection would later use.
|
||||
That last part is where hand-rolled checks fail — see
|
||||
[chapter 4](04-where-the-filter-runs.md).
|
||||
|
||||
Boot 4.1 added
|
||||
[`InetAddressFilter`](https://docs.spring.io/spring-boot/4.1/api/java/org/springframework/boot/http/client/InetAddressFilter.html),
|
||||
declared once as a bean and applied by `HttpClientAutoConfiguration` to every auto-configured
|
||||
HTTP client. The check moves down into the client's own name resolution, which is the only place
|
||||
it can be both mandatory and correctly timed.
|
||||
|
||||
It is a real improvement and it is easy to configure backwards. The next three chapters are
|
||||
about that.
|
||||
|
||||
[The exploit →](02-the-exploit.md)
|
||||
49
ssrf/docs/02-the-exploit.md
Normal file
49
ssrf/docs/02-the-exploit.md
Normal file
@@ -0,0 +1,49 @@
|
||||
[← What SSRF costs you](01-what-ssrf-costs-you.md) · [Module README](../README.md) · [Allow, not block →](03-allow-not-block.md)
|
||||
|
||||
# 2. The exploit, start to finish
|
||||
|
||||
Run the application with no profile, so there is no `InetAddressFilter` bean at all:
|
||||
|
||||
```bash
|
||||
./scripts/run.sh
|
||||
./scripts/exploit.sh
|
||||
```
|
||||
|
||||
The transcript is committed at
|
||||
[`docs/output/exploit-by-profile.txt`](output/exploit-by-profile.txt). The first block is this
|
||||
one:
|
||||
|
||||
```
|
||||
http://127.0.0.1:8080/internal/credentials FETCHED | {"SecretAccessKey":"wJalrXUtnFEMI-...
|
||||
http://localhost:8080/internal/credentials FETCHED | {"SecretAccessKey":"wJalrXUtnFEMI-...
|
||||
http://[::1]:8080/internal/credentials FETCHED | {"SecretAccessKey":"wJalrXUtnFEMI-...
|
||||
http://172.16.10.3:8080/internal/credentials FETCHED | {"SecretAccessKey":"wJalrXUtnFEMI-...
|
||||
http://example.com/ FETCHED | <!doctype html>...
|
||||
```
|
||||
|
||||
Four targets, four sets of credentials, and the legitimate outbound call still works. That last
|
||||
row matters as much as the others: any mitigation has to leave it intact, and one of the
|
||||
configurations in [chapter 3](03-allow-not-block.md) does not.
|
||||
|
||||
## Why four targets and not one
|
||||
|
||||
`127.0.0.1` is the one every tutorial blocks. The others are why a hand-written check usually
|
||||
leaks:
|
||||
|
||||
| Target | What it defeats |
|
||||
|---|---|
|
||||
| `localhost` | checks written against the literal string `127.0.0.1` |
|
||||
| `[::1]` | checks that only ever consider IPv4 |
|
||||
| `172.16.10.3` | checks that stop at loopback and forget RFC 1918 |
|
||||
|
||||
The fourth is this container's own address on its network interface. It is the same process,
|
||||
reached the same way, over a route that a loopback-only rule does not cover. In a real
|
||||
deployment it is the pod next door.
|
||||
|
||||
Blocking by string is hopeless in a way that is easy to underestimate. `0x7f.0.0.1`,
|
||||
`2130706433`, `127.1`, a DNS name you control that resolves to `127.0.0.1`, and a redirect from a
|
||||
public URL to a private one all reach loopback without the string `127.0.0.1` appearing anywhere
|
||||
in the request. This is why the check belongs at address-resolution time and not in a validator
|
||||
over the URL — which is exactly what `InetAddressFilter` is.
|
||||
|
||||
[Allow, not block →](03-allow-not-block.md)
|
||||
115
ssrf/docs/03-allow-not-block.md
Normal file
115
ssrf/docs/03-allow-not-block.md
Normal file
@@ -0,0 +1,115 @@
|
||||
[← The exploit](02-the-exploit.md) · [Module README](../README.md) · [Where the filter runs →](04-where-the-filter-runs.md)
|
||||
|
||||
# 3. `matches` means allow
|
||||
|
||||
This is the chapter that matters. Get this backwards and you ship a service that still leaks and
|
||||
also cannot make its own outbound calls.
|
||||
|
||||
## What the sources say
|
||||
|
||||
The Spring Boot 4.1 release notes:
|
||||
|
||||
> Both reactive and blocking HTTP clients can now be configured with an `InetAddressFilter`
|
||||
> which can **block** outgoing requests to specific addresses.
|
||||
|
||||
The reference documentation, one click further in:
|
||||
|
||||
> To limit the address that a client can call, you can use an `InetAddressFilter` which will
|
||||
> **only allow** outgoing calls to addresses that match the filter.
|
||||
|
||||
Those describe opposite configurations, and the release-notes sentence is the one that got
|
||||
copied into the write-ups. The reference documentation is the correct one, and the bytecode
|
||||
agrees with it. `FilteredAddresses.of(stream, predicate)` filters the resolved addresses
|
||||
*through* the predicate and keeps what matches; `Filtered.orElseThrow` raises
|
||||
`FilteredHostException` when nothing is left:
|
||||
|
||||
```
|
||||
T orElseThrow(Supplier<String>, InetAddressFilter):
|
||||
if (result == null || check.test(result)) throw new FilteredHostException(...)
|
||||
return result
|
||||
```
|
||||
|
||||
So: **the filter is an allow-list. An address that matches is permitted. An address that does
|
||||
not match is dropped, and if every address is dropped the call fails.**
|
||||
|
||||
## The inversion, run
|
||||
|
||||
The `blocklist` profile is what you write if you act on the word "block" — name the private
|
||||
ranges you want forbidden:
|
||||
|
||||
```java
|
||||
InetAddressFilter.of("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16");
|
||||
```
|
||||
|
||||
From [`docs/output/exploit-by-profile.txt`](output/exploit-by-profile.txt):
|
||||
|
||||
```
|
||||
http://127.0.0.1:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '127.0.0.1'
|
||||
http://172.16.10.3:8080/internal/credentials FETCHED | {"Expiration":"2026-08-...
|
||||
http://example.com/ BLOCKED_BY_FILTER | Filtered host 'example.com'
|
||||
```
|
||||
|
||||
Read those three lines together. The RFC 1918 target — the one the configuration was written to
|
||||
forbid — **succeeds**. The legitimate call to `example.com` **fails**. The configuration
|
||||
achieved precisely the opposite of its intent in both directions.
|
||||
|
||||
The loopback row still blocks, which is the cruel part: the naive exploit everyone tests with
|
||||
stops working, so the change looks like it worked.
|
||||
|
||||
## What the factory methods actually contain
|
||||
|
||||
`specialPurpose()` is documented as "special purpose IP addresses as defined by RFC 6890". Its
|
||||
constant pool holds 25 CIDR strings, and **none of them is an RFC 1918 range**:
|
||||
|
||||
```
|
||||
0.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 192.0.0.0/24 192.0.0.0/29
|
||||
192.0.2.0/24 192.88.99.0/24 198.18.0.0/15 198.51.100.0/24 203.0.113.0/24
|
||||
240.0.0.0/4 255.255.255.255/32 ::/128 ::1/128 64:ff9b::/96 100::/64
|
||||
2001::/23 2001::/32 2001:2::/48 2001:db8::/32 2001:10::/28 2002::/16
|
||||
fc00::/7 fe80::/10
|
||||
```
|
||||
|
||||
It still matches `10.0.0.1`, because the method is not just that list:
|
||||
|
||||
```java
|
||||
specialPurpose() = of(<the 25 CIDRs>).or(InternalInetAddressFilter.instance)
|
||||
```
|
||||
|
||||
and `InternalInetAddressFilter` is `isLoopbackAddress() || isLinkLocalAddress() ||
|
||||
isSiteLocalAddress()`, plus an IPv6 arm that also decodes **NAT64-embedded** addresses
|
||||
(`64:ff9b::a00:1` is `10.0.0.1` wearing a hat) and re-tests the embedded IPv4. The RFC 1918
|
||||
coverage comes from the JDK's own predicates, not from the registry list.
|
||||
|
||||
That is worth knowing before you build anything on top of `specialPurpose()`, because its name
|
||||
and its javadoc both suggest it is the RFC 6890 registry and only the registry.
|
||||
|
||||
## `internalAddresses().negate()` is not `externalAddresses()`
|
||||
|
||||
They look interchangeable. They are not, and
|
||||
[`docs/output/filter-matrix.txt`](output/filter-matrix.txt) has the rows:
|
||||
|
||||
| Address | `externalAddresses()` | `internalAddresses().negate()` |
|
||||
|---|---|---|
|
||||
| `100.64.0.1` (CGNAT) | `false` | **`true`** |
|
||||
| `0.0.0.0` | `false` | **`true`** |
|
||||
| `192.0.2.1` (TEST-NET-1) | `false` | **`true`** |
|
||||
| `224.0.0.1` (multicast) | `false` | **`true`** |
|
||||
|
||||
`internalAddresses()` is `routable().and(InternalInetAddressFilter.instance)` — loopback,
|
||||
link-local and site-local, nothing else. Negating it allows everything that is none of those,
|
||||
and "none of those" includes carrier-grade NAT space, which on a mobile or ISP-adjacent network
|
||||
is emphatically not the public internet.
|
||||
|
||||
`externalAddresses()` is `routable().andNot(multicast(), specialPurpose())`, which is a
|
||||
different and stricter statement. Prefer it.
|
||||
|
||||
## The short version
|
||||
|
||||
| Intent | Write |
|
||||
|---|---|
|
||||
| only call the public internet | `InetAddressFilter.externalAddresses()` |
|
||||
| only call these destinations | `InetAddressFilter.of("203.0.113.0/24", "198.51.100.7")` |
|
||||
| public internet minus a range | `externalAddresses().andNot("203.0.113.0/24")` |
|
||||
| **never** | `InetAddressFilter.of(<the ranges you want to forbid>)` |
|
||||
|
||||
[Where the filter runs →](04-where-the-filter-runs.md)
|
||||
70
ssrf/docs/04-where-the-filter-runs.md
Normal file
70
ssrf/docs/04-where-the-filter-runs.md
Normal file
@@ -0,0 +1,70 @@
|
||||
[← Allow, not block](03-allow-not-block.md) · [Module README](../README.md) · [Wiring it up →](05-wiring-it-up.md)
|
||||
|
||||
# 4. Where the filter runs depends on your HTTP client
|
||||
|
||||
One `InetAddressFilter` bean, four different insertion points. Boot picks the one that fits
|
||||
whichever client is on the classpath:
|
||||
|
||||
| Client | Class that applies the filter | Hook |
|
||||
|---|---|---|
|
||||
| Apache HttpComponents | `HttpComponentsFilteredDnsResolver` | `DnsResolver` |
|
||||
| JDK `HttpClient` | `JdkFilteredProxySelector` | `ProxySelector` |
|
||||
| Jetty | `JettyFilteredSocketAddressResolver` | `SocketAddressResolver` |
|
||||
| Reactor Netty | `ReactorFilteredResolvedAddressSelector` | resolved-address selector |
|
||||
|
||||
Three of those are name-resolution hooks. The JDK one is not, because `java.net.http.HttpClient`
|
||||
does not expose a resolver — so Boot filters in the `ProxySelector`, which is consulted per
|
||||
request and is handed a `URI` and nothing else.
|
||||
|
||||
That difference is not cosmetic. It is pinned down by
|
||||
[`WhereTheFilterRunsTests`](../src/test/java/org/springframework/boot/http/client/WhereTheFilterRunsTests.java),
|
||||
which lives in `org.springframework.boot.http.client` because both classes are package-private.
|
||||
|
||||
## Apache filters the set; the JDK filters the name
|
||||
|
||||
`HttpComponentsFilteredDnsResolver.resolve` calls the delegate, keeps the addresses that match,
|
||||
and returns the survivors. A host resolving to one private and one public address yields a
|
||||
one-element array containing the public one, and the connection proceeds:
|
||||
|
||||
```java
|
||||
assertThat(filtered.resolve("mixed.example")).hasSize(1)
|
||||
.extracting(InetAddress::getHostAddress).containsExactly("93.184.216.34");
|
||||
```
|
||||
|
||||
It throws only when nothing survives. The connection then uses exactly the addresses that were
|
||||
vetted, in the same lookup — there is no second resolution and therefore no window.
|
||||
|
||||
`JdkFilteredProxySelector.select` has no addresses to work with, so it does its own lookup:
|
||||
|
||||
```java
|
||||
private @Nullable InetAddress resolve(String host) {
|
||||
try { return InetAddress.getByName(host); }
|
||||
catch (UnknownHostException ex) { return null; }
|
||||
}
|
||||
```
|
||||
|
||||
`getByName` returns **one** address. The decision is all-or-nothing, and the address that was
|
||||
vetted is not necessarily the address the connection later opens. Between `select()` and the
|
||||
socket there is a second resolution, which is the classic DNS-rebinding window: a hostname whose
|
||||
record has a short TTL and answers with a public address once and a private address next.
|
||||
|
||||
Nothing about this is Spring's fault — the JDK client offers no better hook — but it means the
|
||||
strength of your SSRF mitigation depends on a dependency you may not have thought of as a
|
||||
security control. **If this filter is load-bearing, put `httpclient5` on the classpath.**
|
||||
|
||||
## A typo reads as a policy violation
|
||||
|
||||
`resolve` swallows `UnknownHostException` and returns `null`; `matchesResolvedHost` reads `null`
|
||||
as "does not match". So on the JDK path:
|
||||
|
||||
```java
|
||||
assertThatExceptionOfType(FilteredHostException.class)
|
||||
.isThrownBy(() -> filtered.select(URI.create("http://no-such-host.invalid/")))
|
||||
.withMessage("Filtered host 'no-such-host.invalid'");
|
||||
```
|
||||
|
||||
A hostname that does not resolve is reported as **filtered**, not as unknown. Someone debugging
|
||||
that message will go and read the allow-list, which is the wrong file. Worth knowing before it
|
||||
costs you an afternoon.
|
||||
|
||||
[Wiring it up →](05-wiring-it-up.md)
|
||||
98
ssrf/docs/05-wiring-it-up.md
Normal file
98
ssrf/docs/05-wiring-it-up.md
Normal file
@@ -0,0 +1,98 @@
|
||||
[← Where the filter runs](04-where-the-filter-runs.md) · [Module README](../README.md) · [Operating it →](06-operating-it.md)
|
||||
|
||||
# 5. Wiring it up, and the three ways it silently does nothing
|
||||
|
||||
## The bean
|
||||
|
||||
```java
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
public class OutboundConfiguration {
|
||||
|
||||
@Bean
|
||||
InetAddressFilter httpClientInetAddressFilter() {
|
||||
return InetAddressFilter.externalAddresses();
|
||||
}
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
`HttpClientAutoConfiguration.httpClientSettings` reads it and folds it into the shared
|
||||
`HttpClientSettings`:
|
||||
|
||||
```java
|
||||
InetAddressFilter filter = inetAddressFilter.getIfAvailable();
|
||||
HttpClientSettings settings = (filter != null)
|
||||
? HttpClientSettings.defaults().withInetAddressFilter(filter)
|
||||
: HttpClientSettings.defaults();
|
||||
```
|
||||
|
||||
Note that `HttpClientSettings.defaults()` is the all-null record — the default filter is `null`,
|
||||
not `all()`.
|
||||
|
||||
## There is no property for it
|
||||
|
||||
`HttpClientSettingsProperties` carries `redirects`, `connectTimeout`, `readTimeout`,
|
||||
`cookieHandling` and `ssl`. There is no `spring.http.clients.inet-address-filter`. Configuration
|
||||
is a bean or an explicit `HttpClientSettings`, and nothing else — so it cannot be turned on per
|
||||
environment from a config server, and it cannot be turned off in an incident without a deploy.
|
||||
|
||||
Plan for that: put the filter behind a `@Profile` or a `@ConditionalOnProperty` yourself if you
|
||||
need a switch.
|
||||
|
||||
## Failure 1 — the starter does not bring it
|
||||
|
||||
`spring-boot-starter-web` alone does **not** put `InetAddressFilter` on the classpath, and does
|
||||
not give you an auto-configured `RestClient.Builder` either. Boot 4 split the HTTP client
|
||||
modules apart. The compile error is the good outcome:
|
||||
|
||||
```
|
||||
cannot find symbol
|
||||
symbol: class FilteredHostException
|
||||
```
|
||||
|
||||
Add `spring-boot-starter-restclient` (or `-webclient`), which pulls in `spring-boot-restclient`
|
||||
and through it `spring-boot-http-client`. This module's
|
||||
[`pom.xml`](../pom.xml) does exactly that.
|
||||
|
||||
## Failure 2 — a client you built yourself
|
||||
|
||||
The filter reaches auto-configured builders. A `RestClient.create()` or a `new RestTemplate()`
|
||||
written inside your own class is not one, and no bean will change it. That is why
|
||||
[`LinkPreviewController`](../src/main/java/com/ankurm/ssrf/LinkPreviewController.java) takes
|
||||
`RestClient.Builder` in its constructor.
|
||||
|
||||
For a hand-built client, apply the filter yourself:
|
||||
|
||||
```java
|
||||
HttpClientSettings settings = HttpClientSettings.defaults()
|
||||
.withInetAddressFilter(InetAddressFilter.externalAddresses());
|
||||
ClientHttpRequestFactory factory = ClientHttpRequestFactoryBuilder.jdk().build(settings);
|
||||
```
|
||||
|
||||
And note what is still not covered: anything that opens a socket without going through a Spring
|
||||
HTTP client. A JDBC URL, a raw `URL.openStream()`, an SDK with its own transport, a
|
||||
`ProcessBuilder` running `curl`. `InetAddressFilter` is a control on Spring's HTTP clients, not
|
||||
an egress policy for the JVM. If you need the latter, it belongs in the network.
|
||||
|
||||
## Failure 3 — two beans, and a diagnostic that blames the wrong thing
|
||||
|
||||
`getIfAvailable()` is not "pick one". Two `InetAddressFilter` beans and the context does not
|
||||
start — see [`docs/output/two-filter-beans.txt`](output/two-filter-beans.txt):
|
||||
|
||||
```
|
||||
No qualifying bean of type 'org.springframework.boot.http.client.InetAddressFilter' available:
|
||||
expected single matching bean but found 2: firstFilter,secondFilter
|
||||
```
|
||||
|
||||
but the framed message Boot prints underneath names something four levels away:
|
||||
|
||||
```
|
||||
Description:
|
||||
Parameter 0 of method restClientBuilder in ...RestClientAutoConfiguration required a single
|
||||
bean, but 2 were found:
|
||||
```
|
||||
|
||||
The words `InetAddressFilter` do not appear in the part everyone reads. If you are merging two
|
||||
starters or two shared config modules, this failure will look like a `RestClient` problem.
|
||||
|
||||
[Operating it →](06-operating-it.md)
|
||||
67
ssrf/docs/06-operating-it.md
Normal file
67
ssrf/docs/06-operating-it.md
Normal file
@@ -0,0 +1,67 @@
|
||||
[← Wiring it up](05-wiring-it-up.md) · [Module README](../README.md) · [Composing filters →](07-composing-filters.md)
|
||||
|
||||
# 6. Operating it
|
||||
|
||||
## What the caller sees
|
||||
|
||||
`FilteredHostException` is a plain `RuntimeException`. Uncaught in a controller it is a bare
|
||||
**HTTP 500**, and Boot's default error body does not name the host — so the first symptom in
|
||||
production is a 500 with nothing useful in the response and a stack trace in the log.
|
||||
|
||||
Catch it. It carries the two things you want:
|
||||
|
||||
```java
|
||||
catch (FilteredHostException ex) {
|
||||
log.warn("outbound call to {} blocked by {}", ex.getHost(), ex.getFilter());
|
||||
return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(...);
|
||||
}
|
||||
```
|
||||
|
||||
`getHost()` is the host **string from the URI**, not the resolved address — `Filtered host
|
||||
'localhost'`, not `Filtered host '127.0.0.1'`. On the Apache path the resolved addresses were
|
||||
available and were not kept, so if you want to log which address triggered it you have to
|
||||
resolve again yourself.
|
||||
|
||||
A 4xx is arguably the better answer when the URL came from a user, since the request is what is
|
||||
wrong. Do not echo `ex.getMessage()` back to them: it confirms which hosts are unreachable,
|
||||
which turns your error response into a network scanner. Log it, return something bland.
|
||||
|
||||
## Alert on it
|
||||
|
||||
A `FilteredHostException` is either an attack or an outage, and you want to know which. Both are
|
||||
worth paging on eventually, but the second is the one that will bite you first: an allow-list
|
||||
pinned to IP ranges fails the day the destination changes its DNS. The `allowlist` profile in
|
||||
this module was written against `93.184.216.34`, which was example.com's address for a decade
|
||||
and is not any more.
|
||||
|
||||
## Testing it
|
||||
|
||||
The filter is a `@FunctionalInterface` with no Spring dependencies, so the allow/deny decision
|
||||
is a unit test — no context, no network:
|
||||
|
||||
```java
|
||||
assertThat(InetAddressFilter.externalAddresses()
|
||||
.matches(InetAddress.getByName("169.254.169.254"))).isFalse();
|
||||
```
|
||||
|
||||
[`FilterMatrix`](../src/main/java/com/ankurm/ssrf/FilterMatrix.java) is that idea with a
|
||||
table around it. Run it against your own filter before you deploy it; the four-row disagreement
|
||||
in [chapter 3](03-allow-not-block.md) is not something you would find by reading.
|
||||
|
||||
## The diagnostic endpoint
|
||||
|
||||
[`/diag/filter?host=...`](../src/main/java/com/ankurm/ssrf/DiagnosticsController.java) reports
|
||||
whether a filter bean is present, whether the settings picked it up, and the verdict on every
|
||||
address the host resolves to:
|
||||
|
||||
```json
|
||||
{ "filterBeanPresent": true, "settingsCarryFilter": true, "host": "example.com",
|
||||
"resolvesTo": { "172.66.147.243": true, "104.20.23.154": true } }
|
||||
```
|
||||
|
||||
That answers the question you actually have when an outbound call fails, which is not "what does
|
||||
my configuration say" but "what does the running context think". **Delete it before shipping**:
|
||||
it is an oracle for your outbound allow-list and a host-resolution service for anyone who finds
|
||||
it.
|
||||
|
||||
[Composing filters →](07-composing-filters.md)
|
||||
67
ssrf/docs/07-composing-filters.md
Normal file
67
ssrf/docs/07-composing-filters.md
Normal file
@@ -0,0 +1,67 @@
|
||||
[← Operating it](06-operating-it.md) · [Module README](../README.md)
|
||||
|
||||
# 7. Composing filters, and the vararg that matches nothing
|
||||
|
||||
`InetAddressFilter` has `and`, `or`, `andNot` and `negate`, each with three overloads. The
|
||||
`String...` overloads do not mean what the symmetry suggests.
|
||||
|
||||
## `of(a, b)` ORs. `and(a, b)` does not.
|
||||
|
||||
```java
|
||||
public default InetAddressFilter and(String... addresses) {
|
||||
return and(Arrays.stream(addresses).map(IpAddress::of).map(...).toList());
|
||||
}
|
||||
```
|
||||
|
||||
Each address becomes **its own filter**, and `and(Collection)` folds the whole list with logical
|
||||
AND. So `and("104.16.0.0/12", "172.64.0.0/13")` asks for an address inside *both* ranges. No
|
||||
address is inside two disjoint ranges, so the filter matches nothing and every outbound call
|
||||
fails.
|
||||
|
||||
Run [`AndVarargsTrap`](../src/main/java/com/ankurm/ssrf/AndVarargsTrap.java) —
|
||||
[`docs/output/and-varargs-trap.txt`](output/and-varargs-trap.txt):
|
||||
|
||||
```
|
||||
address under test: 104.20.23.154 (inside 104.16.0.0/12, outside 172.64.0.0/13)
|
||||
|
||||
of("104.16.0.0/12") -> true
|
||||
of("104.16.0.0/12", "172.64.0.0/13") -> true
|
||||
|
||||
externalAddresses().and("104.16.0.0/12") -> true
|
||||
externalAddresses().and("104.16.0.0/12", "172.64.0.0/13") -> false
|
||||
externalAddresses().and(of("104.16.0.0/12", "172.64.0.0/13")) -> true
|
||||
```
|
||||
|
||||
The javadoc says the addresses are ANDed with the filter "in any form supported by
|
||||
`of(String...)`", which reads as though they are combined the way `of` combines them. They are
|
||||
not: that phrase is about the format of each string.
|
||||
|
||||
**Rule: whenever you pass more than one address to `and`, wrap them in `of` first.** One address
|
||||
is safe; two silently is not. There is no warning, no log line, and the symptom is that
|
||||
everything is blocked — which looks like the filter working.
|
||||
|
||||
`andNot(a, b)` is fine, because "not a AND not b" is what you want from a subtraction, and it is
|
||||
how `externalAddresses()` itself is built:
|
||||
|
||||
```java
|
||||
externalAddresses() = routable().andNot(multicast(), specialPurpose())
|
||||
```
|
||||
|
||||
`or(a, b)` is also fine.
|
||||
|
||||
## A useful shape
|
||||
|
||||
Public internet, minus a range you know is hostile, plus one internal service you legitimately
|
||||
call:
|
||||
|
||||
```java
|
||||
InetAddressFilter.externalAddresses()
|
||||
.andNot("203.0.113.0/24")
|
||||
.or(InetAddressFilter.of("10.20.30.40"));
|
||||
```
|
||||
|
||||
Read it left to right and check it against `FilterMatrix` before you believe it. Boolean
|
||||
composition of allow-lists is the kind of thing that is obvious while you write it and wrong
|
||||
when you read it back.
|
||||
|
||||
[Module README](../README.md)
|
||||
11
ssrf/docs/output/and-varargs-trap.txt
Normal file
11
ssrf/docs/output/and-varargs-trap.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
address under test: 104.20.23.154 (inside 104.16.0.0/12, outside 172.64.0.0/13)
|
||||
|
||||
of("104.16.0.0/12") -> true
|
||||
of("104.16.0.0/12", "172.64.0.0/13") -> true
|
||||
|
||||
externalAddresses().and("104.16.0.0/12") -> true
|
||||
externalAddresses().and("104.16.0.0/12", "172.64.0.0/13") -> false
|
||||
externalAddresses().and(of("104.16.0.0/12", "172.64.0.0/13")) -> true
|
||||
|
||||
The fourth line is the trap. One address is one filter; two addresses
|
||||
are two filters ANDed, and no address is inside two disjoint ranges.
|
||||
55
ssrf/docs/output/exploit-by-profile.txt
Normal file
55
ssrf/docs/output/exploit-by-profile.txt
Normal file
@@ -0,0 +1,55 @@
|
||||
===================================================================================
|
||||
PROFILE: (none) - no InetAddressFilter bean
|
||||
===================================================================================
|
||||
target outcome
|
||||
-----------------------------------------------------------------------------------------
|
||||
http://127.0.0.1:8080/internal/credentials FETCHED | {"SecretAccessKey":"wJalrXUtnFEMI-EXAMPLE-NOT-REAL","AccessKeyId":"ASIA-EXAMPLE-NOT-REAL","Expir
|
||||
http://localhost:8080/internal/credentials FETCHED | {"SecretAccessKey":"wJalrXUtnFEMI-EXAMPLE-NOT-REAL","AccessKeyId":"ASIA-EXAMPLE-NOT-REAL","Expir
|
||||
http://[::1]:8080/internal/credentials FETCHED | {"SecretAccessKey":"wJalrXUtnFEMI-EXAMPLE-NOT-REAL","AccessKeyId":"ASIA-EXAMPLE-NOT-REAL","Expir
|
||||
http://172.16.10.3:8080/internal/credentials FETCHED | {"SecretAccessKey":"wJalrXUtnFEMI-EXAMPLE-NOT-REAL","AccessKeyId":"ASIA-EXAMPLE-NOT-REAL","Expir
|
||||
http://example.com/ FETCHED | <!doctype html><html lang="en"><head><title>Example Domain</title><link rel="icon" href="data:,"
|
||||
|
||||
===================================================================================
|
||||
PROFILE: docsfilter
|
||||
===================================================================================
|
||||
target outcome
|
||||
-----------------------------------------------------------------------------------------
|
||||
http://127.0.0.1:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '127.0.0.1'
|
||||
http://localhost:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host 'localhost'
|
||||
http://[::1]:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '[::1]'
|
||||
http://172.16.10.3:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '172.16.10.3'
|
||||
http://example.com/ FETCHED | <!doctype html><html lang="en"><head><title>Example Domain</title><link rel="icon" href="data:,"
|
||||
|
||||
===================================================================================
|
||||
PROFILE: blocklist
|
||||
===================================================================================
|
||||
target outcome
|
||||
-----------------------------------------------------------------------------------------
|
||||
http://127.0.0.1:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '127.0.0.1'
|
||||
http://localhost:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host 'localhost'
|
||||
http://[::1]:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '[::1]'
|
||||
http://172.16.10.3:8080/internal/credentials FETCHED | {"Expiration":"2026-08-29T23:59:59Z","AccessKeyId":"ASIA-EXAMPLE-NOT-REAL","SecretAccessKey":"wJ
|
||||
http://example.com/ BLOCKED_BY_FILTER | Filtered host 'example.com'
|
||||
|
||||
===================================================================================
|
||||
PROFILE: negated
|
||||
===================================================================================
|
||||
target outcome
|
||||
-----------------------------------------------------------------------------------------
|
||||
http://127.0.0.1:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '127.0.0.1'
|
||||
http://localhost:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host 'localhost'
|
||||
http://[::1]:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '[::1]'
|
||||
http://172.16.10.3:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '172.16.10.3'
|
||||
http://example.com/ FETCHED | <!doctype html><html lang="en"><head><title>Example Domain</title><link rel="icon" href="data:,"
|
||||
|
||||
===================================================================================
|
||||
PROFILE: allowlist
|
||||
===================================================================================
|
||||
target outcome
|
||||
-----------------------------------------------------------------------------------------
|
||||
http://127.0.0.1:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '127.0.0.1'
|
||||
http://localhost:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host 'localhost'
|
||||
http://[::1]:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '[::1]'
|
||||
http://172.16.10.3:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '172.16.10.3'
|
||||
http://example.com/ FETCHED | <!doctype html><html lang="en"><head><title>Example Domain</title><link rel="icon" href="data:,"
|
||||
|
||||
20
ssrf/docs/output/filter-matrix.txt
Normal file
20
ssrf/docs/output/filter-matrix.txt
Normal file
@@ -0,0 +1,20 @@
|
||||
true = the filter MATCHES the address = the call is ALLOWED through.
|
||||
A row that is false in the active filter's column throws FilteredHostException.
|
||||
|
||||
address | all() | none() | routable() | multicast() | specialPurpose() | internalAddresses() | externalAddresses() | internalAddresses().negate()| of(RFC1918) [the inversion]
|
||||
----------------------+---------------------------+---------------------------+---------------------------+---------------------------+---------------------------+---------------------------+---------------------------+---------------------------+---------------------------
|
||||
127.0.0.1 | true | false | true | false | true | true | false | false | false
|
||||
169.254.169.254 | true | false | true | false | true | true | false | false | false
|
||||
10.0.0.1 | true | false | true | false | true | true | false | false | true
|
||||
172.16.10.3 | true | false | true | false | true | true | false | false | true
|
||||
192.168.1.1 | true | false | true | false | true | true | false | false | true
|
||||
100.64.0.1 | true | false | true | false | true | false | false | true | false
|
||||
0.0.0.0 | true | false | false | false | true | false | false | true | false
|
||||
192.0.2.1 | true | false | true | false | true | false | false | true | false
|
||||
224.0.0.1 | true | false | true | true | false | false | false | true | false
|
||||
93.184.216.34 | true | false | true | false | false | false | true | true | false
|
||||
::1 | true | false | true | false | true | true | false | false | false
|
||||
fc00::1 | true | false | true | false | true | true | false | false | false
|
||||
fe80::1 | true | false | true | false | true | true | false | false | false
|
||||
64:ff9b::a00:1 | true | false | true | false | true | true | false | false | false
|
||||
2606:2800:220:1::1 | true | false | true | false | false | false | true | true | false
|
||||
3
ssrf/docs/output/tests.txt
Normal file
3
ssrf/docs/output/tests.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
[INFO] Running org.springframework.boot.http.client.WhereTheFilterRunsTests
|
||||
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.221 s -- in org.springframework.boot.http.client.WhereTheFilterRunsTests
|
||||
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
|
||||
6
ssrf/docs/output/two-filter-beans.txt
Normal file
6
ssrf/docs/output/two-filter-beans.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
$ java -cp ... SsrfDemoApplication --spring.profiles.active=twofilters
|
||||
2026-08-29T09:28:27.206+05:30 WARN 445 --- [ssrf-inet-address-filter] [ main] ConfigServletWebServerApplicationContext : Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'linkPreviewController' defined in file [/tmp/work/ssd/ssrf/target/classes/com/ankurm/ssrf/LinkPreviewController.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'restClientBuilder' defined in class path resource [org/springframework/boot/restclient/autoconfigure/RestClientAutoConfiguration.class]: Unsatisfied dependency expressed through method 'restClientBuilder' parameter 0: Error creating bean with name 'restClientBuilderConfigurer' defined in class path resource [org/springframework/boot/restclient/autoconfigure/RestClientAutoConfiguration.class]: Failed to instantiate [org.springframework.boot.restclient.autoconfigure.RestClientBuilderConfigurer]: Factory method 'restClientBuilderConfigurer' threw exception with message: Error creating bean with name 'httpClientSettings' defined in class path resource [org/springframework/boot/http/client/autoconfigure/HttpClientAutoConfiguration.class]: Failed to instantiate [org.springframework.boot.http.client.HttpClientSettings]: Factory method 'httpClientSettings' threw exception with message: No qualifying bean of type 'org.springframework.boot.http.client.InetAddressFilter' available: expected single matching bean but found 2: firstFilter,secondFilter
|
||||
APPLICATION FAILED TO START
|
||||
Description:
|
||||
Parameter 0 of method restClientBuilder in org.springframework.boot.restclient.autoconfigure.RestClientAutoConfiguration required a single bean, but 2 were found:
|
||||
Action:
|
||||
60
ssrf/pom.xml
Normal file
60
ssrf/pom.xml
Normal file
@@ -0,0 +1,60 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<!-- Inheriting spring-boot-starter-parent so every version below is Boot-managed. The
|
||||
feature this module demonstrates - org.springframework.boot.http.client.InetAddressFilter
|
||||
- is new in Boot 4.1 and lives in spring-boot-http-client, which arrives transitively
|
||||
with spring-boot-starter-web. See docs/01-what-ssrf-costs-you.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>ssrf-inet-address-filter</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>
|
||||
<!-- Boot 4 modularised the HTTP client story: spring-boot-starter-web does NOT bring an
|
||||
auto-configured RestClient.Builder, and it does not put InetAddressFilter on the
|
||||
classpath either. This starter is what pulls in spring-boot-restclient and, through
|
||||
it, spring-boot-http-client. See docs/05-wiring-it-up.md. -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-restclient</artifactId>
|
||||
</dependency>
|
||||
<!-- Present ONLY so the apacheclient profile has a second request factory to select.
|
||||
Which client is on the classpath changes WHERE the filter runs - a DNS resolver for
|
||||
Apache, a ProxySelector for the JDK client. See docs/04-where-the-filter-runs.md. -->
|
||||
<dependency>
|
||||
<groupId>org.apache.httpcomponents.client5</groupId>
|
||||
<artifactId>httpclient5</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-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>
|
||||
17
ssrf/scripts/exploit.sh
Executable file
17
ssrf/scripts/exploit.sh
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# Drive the vulnerable endpoint against the four targets that matter, printing the outcome of
|
||||
# each. Run it after ./scripts/run.sh <profile>; the profile decides the answers.
|
||||
set -eu
|
||||
PRIVATE_IP="$(hostname -I | awk '{print $1}')"
|
||||
probe() {
|
||||
printf '%-58s ' "$1"
|
||||
curl -s --max-time 10 -G http://127.0.0.1:8080/preview --data-urlencode "url=$1" \
|
||||
| python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["outcome"], "|", d.get("body", d.get("message",""))[:96])'
|
||||
}
|
||||
echo "target outcome"
|
||||
echo "-----------------------------------------------------------------------------------------"
|
||||
probe "http://127.0.0.1:8080/internal/credentials"
|
||||
probe "http://localhost:8080/internal/credentials"
|
||||
probe "http://[::1]:8080/internal/credentials"
|
||||
probe "http://${PRIVATE_IP}:8080/internal/credentials"
|
||||
probe "http://example.com/"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user