1
0

Add the filter-chain module

Companion project for "The Spring Security Filter Chain Explained". A real
Spring Boot 4.1.1 servlet application whose scenarios are Spring profiles, plus
a diagnostic controller that prints the live FilterChainProxy, the reflected
FilterOrderRegistration table, and the servlet container's own registrations.

Twelve captured transcripts under docs/output/, nine cross-linked doc chapters,
21 assertions.

Also fixes a broken relative link in method-security/docs/01: the cross-module
reference to context-propagation/README.md needed two levels up, not one.
This commit is contained in:
2026-08-26 01:59:35 +00:00
parent 5e9e7f1b12
commit 73ab67b171
52 changed files with 3562 additions and 10 deletions

118
filter-chain/README.md Normal file
View File

@@ -0,0 +1,118 @@
# `filter-chain` — the Spring Security filter chain, printed from a running application
Companion project for
[**The Spring Security Filter Chain Explained**](https://ankurm.com/spring-security-filter-chain-explained/)
on ankurm.com.
Every claim the article makes about filter ordering is produced here by a real Boot application
and captured under [`docs/output/`](docs/output/). The chain tables are read back out of the live
`FilterChainProxy` bean; the order numbers are reflected out of `spring-security-config`'s own
`FilterOrderRegistration`. Nothing is transcribed from documentation.
## Versions
| | Version | Notes |
|---|---|---|
| JDK | 25 (Temurin 25.0.4.1+1) | current LTS |
| Spring Boot | 4.1.1 | inherited as parent, so every version below is Boot-managed |
| Spring Framework | 7.0.9 | |
| Spring Security | 7.1.1 | latest GA; 7.2.0-M1 is a milestone, not a release |
| Tomcat | 11.0.x | Boot-managed |
| JUnit Jupiter | 6.x / AssertJ 3.x | Boot-managed |
Versions were taken from `repo1.maven.org/.../maven-metadata.xml`, not from release
announcements.
## Quickstart
```bash
./scripts/run.sh baseline # start with the reference configuration
curl -s localhost:8080/diag/chains
curl -s localhost:8080/diag/order
TRACE=1 ./scripts/run.sh baseline
curl -s -u alice:password localhost:8080/whoami # then read /tmp/filter-chain-app.log
./scripts/run-all.sh # every scenario, regenerating docs/output/
mvn test # just the 21 assertions
./scripts/stop.sh
```
Users are `alice` / `password` (`ROLE_USER`) and `root` / `password` (`ROLE_ADMIN`).
## Profiles
Each scenario in the article is a profile on this one application.
| Profile | Configuration | Shows |
|---|---|---|
| `baseline` *(default)* | [`BaselineSecurityConfig`](src/main/java/com/ankurm/chain/config/BaselineSecurityConfig.java) | The reference 16-filter chain |
| `custom` | [`CustomFiltersSecurityConfig`](src/main/java/com/ankurm/chain/config/CustomFiltersSecurityConfig.java) | Four custom filters at four anchors; the `ExceptionTranslationFilter` boundary |
| `misordered` | [`MisorderedSecurityConfig`](src/main/java/com/ankurm/chain/config/MisorderedSecurityConfig.java) | An authentication filter after `AuthorizationFilter` — 401 with a valid credential |
| `tie` | [`TieSecurityConfig`](src/main/java/com/ankurm/chain/config/TieSecurityConfig.java) | Two filters on one anchor. `-DTIE_REVERSED=true` flips them |
| `doublereg` | [`DoubleRegistrationConfig`](src/main/java/com/ankurm/chain/config/DoubleRegistrationConfig.java) | A filter bean registered twice. Add `,fixed` for the cure |
| `multichain` | [`MultiChainSecurityConfig`](src/main/java/com/ankurm/chain/config/MultiChainSecurityConfig.java) | Three chains of three different lengths |
| `ignoring` | [`IgnoringSecurityConfig`](src/main/java/com/ankurm/chain/config/IgnoringSecurityConfig.java) | `WebSecurity.ignoring()` — a chain with zero filters |
Two JVM flags change behaviour rather than configuration:
| Flag | Effect |
|---|---|
| `-DTIE_REVERSED=true` | Swaps the two `addFilterBefore` calls in the `tie` profile |
| `-DUNIQUE_ONCE_KEY=true` | Gives each `TenantFilter` its own `OncePerRequestFilter` key, so the second one stops silently skipping itself |
## Endpoints
| Endpoint | Purpose |
|---|---|
| `GET /diag/chains` | Every `SecurityFilterChain` in `FilterChainProxy`, with each chain's filters in order |
| `GET /diag/order` | The whole `FilterOrderRegistration` table, marking slots whose class is absent |
| `GET /diag/servlet-filters` | What the **container** has registered — where double registration shows up |
| `GET /whoami` | Who the chain decided you are by the time a controller runs |
| `GET /public/hello` | `permitAll` |
| `GET /static/asset.txt` | Under `ignoring()` in the `ignoring` profile |
| `GET /api/data` | Served by the API chain under `multichain` |
| `POST /hello` | For provoking a CSRF rejection |
| `GET /tenant/doc`, `GET /tenant/translated` | Guarded by `TenantFilter`s either side of `ExceptionTranslationFilter` |
| `GET /markers` | Executed order of the tied marker filters |
The `/diag/*` endpoints are the interesting part of this project and also the reason you would
never ship it. Delete `DiagnosticsController` before deploying anything resembling this.
## Documentation
Nine chapters under [`docs/`](docs/README.md), starting with
[01 · The two proxies](docs/01-the-two-proxies.md).
## What this module found
Things that are true of Spring Security 7.1.1 and are not in the reference documentation:
- The default chain is **sixteen** filters, not the fifteen the docs list —
`DefaultResourcesFilter` (2400) is missing from that sample, and the startup log format has
changed too.
- Orders **300** and **4100** name `ChannelProcessingFilter` and `FilterSecurityInterceptor`,
both **removed in 7.0**. The slots were kept so no other number moved.
- `addFilterBefore` resolves its anchor against a **static table**, not against the chain being
built — so you can anchor to a filter you have disabled, and it still works.
- Two filters on the same anchor get the **same order number**; the tie is broken by
`List.sort` being stable, which is a `java.util.List` guarantee and not a Spring Security one.
- Two instances of the same `OncePerRequestFilter` subclass in one chain **share their
already-filtered key**, and the second one silently never runs.
- The documented placement for an authorization filter (after `AnonymousAuthenticationFilter`,
3701) is **below** `ExceptionTranslationFilter` (4000), so the `AccessDeniedException` the
documented example throws produces a 500, not a 403.
- On the `/error` re-dispatch after a rejection, `FilterChainProxy` runs the whole chain again,
but the six `OncePerRequestFilter`-based filters skip themselves — so the error page is
**authorized but not authenticated**.
## Related modules
| Module | Article |
|---|---|
| [`context-propagation/`](../context-propagation/README.md) | [Spring Security Context Propagation](https://ankurm.com/spring-security-context-propagation-complete-guide/) |
| [`method-security/`](../method-security/README.md) | [Method Security in Spring Security 7](https://ankurm.com/spring-security-7-method-security-proxy-traps/) |
## License
MIT — see [`../LICENSE`](../LICENSE).