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

View File

@@ -9,31 +9,39 @@ 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 |
The two are related more closely than they look. Method security reads the `Authentication`
from `SecurityContextHolder` on the calling thread; the context-propagation module is about
getting it there. An `@Async` method carrying `@PreAuthorize` fails with
`AuthenticationCredentialsNotFoundException` for reasons that belong to the first module, not
the second.
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
place fails the same way, for reasons that belong to the first.
## Common ground
Both modules target the same verified stack: **JDK 25** (Temurin 25.0.4.1+1),
All three 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.
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.
## Running either module
## Running a module
```bash
cd method-security # or context-propagation
cd method-security # or context-propagation, or filter-chain
./scripts/run-all.sh # every demo plus the test suite, regenerating docs/output/
mvn test # just the assertions
```
`filter-chain` also has `./scripts/run.sh <profile>` and `./scripts/stop.sh`, because its
scenarios are a running web application rather than a `main()` method.
## License
MIT — see [LICENSE](LICENSE).

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).

View File

@@ -0,0 +1,93 @@
[← docs index](README.md) · **01 · The two proxies** · [02 · The default chain →](02-the-default-chain.md)
# 01 · The two proxies, and why there are two
Spring Security is a single servlet filter. Everything else it does in a web application happens
inside that one filter.
The container knows about exactly one entry:
```
countingFilter com.ankurm.chain.filter.CountingFilter urls=[/*]
springSecurityFilterChain org.springframework.boot.web.servlet.DelegatingFilterProxyRegistrationBean$1 urls=[/*]
```
That comes from [`docs/output/demo12-servlet-filters.txt`](output/demo12-servlet-filters.txt),
printed live from `ServletContext.getFilterRegistrations()`. Note what is *not* in it: none of
the sixteen filters that actually make the security decision. The container cannot see them.
## DelegatingFilterProxy
The container instantiates filters itself, long before Spring's `ApplicationContext` exists, and
it has no idea what a bean is. `DelegatingFilterProxy` bridges that: it is a real
`jakarta.servlet.Filter` registered with the container, and on the first request it looks up a
Spring bean by name and forwards to it.
In Boot the registration is not literally a `DelegatingFilterProxy` — it is an anonymous
subclass created by `DelegatingFilterProxyRegistrationBean`, which is why the class name above
ends in `$1`. Functionally it is the same thing.
Two consequences worth internalising:
- The security chain has a **position in the container's filter list**, and things registered
ahead of it run before any of Spring Security exists for that request. Boot registers it at
order **-100** (`SecurityFilterProperties.DEFAULT_FILTER_ORDER`, settable with
`spring.security.filter.order`), behind `characterEncodingFilter` and `formContentFilter`
(`OrderedFormContentFilter.DEFAULT_ORDER` is -9900).
- It is registered for **every** `DispatcherType`. `SecurityFilterProperties.dispatcherTypes`
defaults to `EnumSet.allOf(DispatcherType.class)`, which is exactly why the whole chain runs a
second time on the internal `/error` dispatch after a rejection — see
[06 · Reading the TRACE output](06-reading-the-trace.md#misleading-thing-3--the-rejected-request-runs-the-chain-twice).
Narrow it with `spring.security.filter.dispatcher-types=request` if you want the security
chain out of error and forward dispatches.
- A `Filter` bean you declare is registered with the *container* automatically. That is the
mechanism behind the double-registration trap in
[05 · Failure modes](05-failure-modes.md#a-custom-filter-that-runs-twice).
> **Boot 4 moved these constants.** `SecurityProperties.DEFAULT_FILTER_ORDER` and
> `SecurityProperties.BASIC_AUTH_ORDER` — the latter a common `@Order(...)` argument — are now
> on `org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterProperties`.
> The values are unchanged (-100 and `Integer.MAX_VALUE - 5`); `SecurityProperties` in 4.1.1
> retains only `getUser()`. `IGNORED_ORDER` does not appear anywhere in the Boot 4.1.1 jars.
## FilterChainProxy
The bean `DelegatingFilterProxy` forwards to is `FilterChainProxy`. It is not a chain; it holds
a list of chains. On each request it walks its `SecurityFilterChain` list in order, asks each one
whether it matches, and **invokes the first one that says yes**. The rest are never consulted —
there is no "and then the next chain too".
It also does three things nothing else does:
1. Applies the `HttpFirewall` before any chain runs. A request rejected here never reaches your
filters, which is why a `RequestRejectedException` looks like it came from nowhere.
2. Clears `SecurityContextHolder` in a `finally` block, so a thread returned to the pool does
not carry someone else's identity. (The
[context-propagation module](../../context-propagation/README.md) is about what happens when you
leave that thread.)
3. Wraps each filter in a decorator. With Micrometer on the classpath — Boot's actuator starter
pulls it in — that decorator is `ObservationFilterChainDecorator`, and you will see two extra
frames per filter in every stack trace:
```
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(...)
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(...)
```
That is from [`demo4-trace-csrf-403.txt`](output/demo4-trace-csrf-403.txt), a real stack
trace from this application. Without Micrometer you get the plain `VirtualFilterChain` and
half the frames.
## SecurityFilterChain
A `RequestMatcher` plus an ordered `List<Filter>`. That is the whole interface. `HttpSecurity`
is a builder that produces one; `@Bean SecurityFilterChain` is how you register it.
The list is ordered by `HttpSecurity.performBuild()`, which sorts with `OrderComparator` and
then throws the ordering wrappers away. By the time `FilterChainProxy` sees the chain, the order
is baked in and no longer inspectable — which is why this module reads the numbers back out of
the [order table](03-the-order-table.md) instead.
---
[← docs index](README.md) · **01 · The two proxies** · [02 · The default chain →](02-the-default-chain.md)

View File

@@ -0,0 +1,69 @@
[← 01 · The two proxies](01-the-two-proxies.md) · **02 · The default chain** · [03 · The order table →](03-the-order-table.md)
# 02 · The default chain, filter by filter
The configuration in [`BaselineSecurityConfig`](../src/main/java/com/ankurm/chain/config/BaselineSecurityConfig.java)
turns on four things — CSRF, HTTP Basic, form login, request authorization — and gets
**sixteen filters**. Captured live in
[`docs/output/demo2-default-chain.txt`](output/demo2-default-chain.txt) and pinned by
`FilterChainContractTest.Baseline.defaultChainIsSixteenFilters`.
| # | Order | Filter | What it does | Skip it and… |
|---|---|---|---|---|
| 1 | 100 | `DisableEncodeUrlFilter` | Stops the container appending `;jsessionid=` to URLs | Session ids leak into logs, referrers and bookmarks |
| 2 | 600 | `WebAsyncManagerIntegrationFilter` | Bridges the `SecurityContext` into `Callable` async MVC | `@Async`-style MVC returns lose the principal |
| 3 | 700 | `SecurityContextHolderFilter` | Installs a **deferred** context supplier; clears the holder afterwards | Nothing loads the stored context |
| 4 | 900 | `HeaderWriterFilter` | `X-Content-Type-Options`, `X-XSS-Protection`, cache headers, HSTS on TLS | No security headers |
| 5 | 1100 | `CsrfFilter` | Validates the token on state-changing methods | CSRF |
| 6 | 1200 | `LogoutFilter` | Matches `POST /logout`, clears the context and session | `/logout` 404s |
| 7 | 2100 | `UsernamePasswordAuthenticationFilter` | Matches `POST /login`, authenticates form credentials | Form login does nothing |
| 8 | 2400 | `DefaultResourcesFilter` | Serves the CSS the generated login page links to | Unstyled default login page |
| 9 | 2500 | `DefaultLoginPageGeneratingFilter` | Renders `GET /login` when you have not supplied one | No login page |
| 10 | 2600 | `DefaultLogoutPageGeneratingFilter` | Renders `GET /logout` confirmation | No logout page |
| 11 | 3100 | `BasicAuthenticationFilter` | Reads the `Authorization: Basic` header | HTTP Basic does nothing |
| 12 | 3300 | `RequestCacheAwareFilter` | Replays the request that triggered a login redirect | Users land on `/` after login instead of where they were |
| 13 | 3400 | `SecurityContextHolderAwareRequestFilter` | Wraps the request so `getUserPrincipal()`/`isUserInRole()` work | Servlet-API security methods return null |
| 14 | 3700 | `AnonymousAuthenticationFilter` | Substitutes an `AnonymousAuthenticationToken` when nothing authenticated | `getAuthentication()` returns `null` and `.anonymous()` rules break |
| 15 | 4000 | `ExceptionTranslationFilter` | Turns `AuthenticationException`/`AccessDeniedException` into 401/403 or a redirect | Denials surface as **500** |
| 16 | 4200 | `AuthorizationFilter` | Runs `authorizeHttpRequests` and denies | Nothing is authorized |
## Three things about that table
**`DefaultResourcesFilter` is the sixteenth.** The list in the reference documentation has
fifteen and omits it; that sample also uses the older `Will secure any request with [ … ]`
wording, which 7.1.1 no longer prints. The current wording is
`Will secure any request with filters: …` — verified against the constant pool of
`DefaultSecurityFilterChain` in 6.5.1, 7.0.7 and 7.1.1, all three of which use the newer form.
If you grep your logs for the bracketed version you will find nothing.
**`SessionManagementFilter` is not there.** Nor is `SecurityContextPersistenceFilter`. Plenty of
articles still list both. `SessionManagementFilter` appears only when you configure
`sessionManagement(…)` explicitly — see the API chain in
[`demo10-multichain.txt`](output/demo10-multichain.txt), where asking for `STATELESS` *adds* a
filter at 3900. `SecurityContextPersistenceFilter` was superseded by `SecurityContextHolderFilter`
in 6.0 and is deprecated.
**Filter 3 does not read the session.** `SecurityContextHolderFilter` installs a
`SupplierDeferredSecurityContext`; the session read happens on first access. In
[`demo3-trace-authenticated.txt`](output/demo3-trace-authenticated.txt) the line
`HttpSessionSecurityContextRepository : No HttpSession currently exists` appears under filter
**11**, not filter 3, because `BasicAuthenticationFilter` was the first thing to ask. That is a
feature — a request that never touches the context never touches the session — and it is also
why reading the TRACE log as a strict timeline misleads. See
[06 · Reading the TRACE output](06-reading-the-trace.md).
## The four events
Everything above collapses into four events, in this order:
1. The `SecurityContext` is made available (filter 3)
2. The request is protected from exploits (filters 45)
3. The request is authenticated (filters 614)
4. The request is authorized (filter 16, with 15 standing by to translate the refusal)
Where a custom filter goes is entirely a question of which of those four have already happened.
That is [04 · Where custom filters land](04-where-custom-filters-land.md).
---
[← 01 · The two proxies](01-the-two-proxies.md) · **02 · The default chain** · [03 · The order table →](03-the-order-table.md)

View File

@@ -0,0 +1,117 @@
[← 02 · The default chain](02-the-default-chain.md) · **03 · The order table** · [04 · Where custom filters land →](04-where-custom-filters-land.md)
# 03 · The order table
The order of the chain is not emergent and it is not the order you write your configuration in.
It is a hard-coded table in a package-private class,
`org.springframework.security.config.annotation.web.builders.FilterOrderRegistration`, built once
in its constructor:
```java
Step order = new Step(100, 100); // INITIAL_ORDER = 100, ORDER_STEP = 100
put(DisableEncodeUrlFilter.class, order.next()); // 100
put(ForceEagerSessionCreationFilter.class, order.next()); // 200
```
`Step.next()` returns the current value and then adds the step, so slots run **100, 200, 300 …**
with 100 free numbers between neighbours. [`FilterOrderTable`](../src/main/java/com/ankurm/chain/support/FilterOrderTable.java)
reads that map back by reflection rather than transcribing it, and
[`demo1-order-table.txt`](output/demo1-order-table.txt) is its output for 7.1.1.
## The full table
```
100 DisableEncodeUrlFilter 2200 OneTimeTokenAuthenticationFilter
200 ForceEagerSessionCreationFilter 2300 -- reserved, nothing registered --
300 ChannelProcessingFilter (removed) 2400 DefaultResourcesFilter
400 HttpsRedirectFilter 2500 DefaultLoginPageGeneratingFilter
500 -- reserved, nothing registered -- 2600 DefaultLogoutPageGeneratingFilter
600 WebAsyncManagerIntegrationFilter 2700 DefaultOneTimeTokenSubmitPageGeneratingFilter
700 SecurityContextHolderFilter 2800 ConcurrentSessionFilter
800 SecurityContextPersistenceFilter 2900 DigestAuthenticationFilter
900 HeaderWriterFilter 3000 BearerTokenAuthenticationFilter
1000 CorsFilter (org.springframework.web) 3100 BasicAuthenticationFilter
1100 CsrfFilter 3200 AuthenticationFilter
1200 LogoutFilter 3300 RequestCacheAwareFilter
1300 OAuth2AuthorizationRequestRedirectFilter 3400 SecurityContextHolderAwareRequestFilter
1400 Saml2WebSsoAuthenticationRequestFilter 3500 JaasApiIntegrationFilter
1500 GenerateOneTimeTokenFilter 3600 RememberMeAuthenticationFilter
1600 X509AuthenticationFilter 3700 AnonymousAuthenticationFilter
1700 AbstractPreAuthenticatedProcessingFilter 3800 OAuth2AuthorizationCodeGrantFilter
1800 CasAuthenticationFilter 3900 SessionManagementFilter
1900 OAuth2LoginAuthenticationFilter 4000 ExceptionTranslationFilter
2000 Saml2WebSsoAuthenticationFilter 4100 FilterSecurityInterceptor (removed)
2100 UsernamePasswordAuthenticationFilter 4200 AuthorizationFilter
4300 SwitchUserFilter
```
Forty-one registered slots, two reserved gaps, orders 100 through 4300.
## Two slots name classes that no longer exist
The map is keyed by **class-name string**, not by `Class`, because many of its entries live in
optional modules — `oauth2-client`, `saml2-service-provider`, `cas`,
`oauth2-resource-server` — that may not be on the classpath. Registering by name means the
table can mention them without failing to load.
Two entries exploit that for a different reason. `ChannelProcessingFilter` (300) and
`FilterSecurityInterceptor` (4100) are both `org.springframework.security.web` classes, and
`spring-security-web` **is** on the classpath — but the classes are not in it:
```
$ unzip -l spring-security-web-6.5.1.jar | grep -c 'access/intercept/FilterSecurityInterceptor.class'
1
$ unzip -l spring-security-web-7.1.1.jar | grep -c 'access/intercept/FilterSecurityInterceptor.class'
0
```
Both were removed in Spring Security 7.0. Their slots were left in the table so that no other
number had to move — the 6.5.1 and 7.1.1 tables are byte-for-byte identical in content.
`FilterOrderTableTest.removedFiltersStillOccupyTheirSlots` asserts exactly this.
The practical consequence is a compile error, not a runtime one. Every pre-7 tutorial that says
```java
http.addFilterBefore(myFilter, FilterSecurityInterceptor.class);
```
no longer compiles. The replacement anchor is `AuthorizationFilter.class`, one slot later at
4200 — so a filter that was at 4099 is now at 4199, and if anything of yours sat between them,
check it.
## Resolution walks up the superclass chain
`getOrder(Class)` does not do a map lookup and give up. It looks the class up, and on a miss
takes `getSuperclass()` and tries again, until it runs out of superclasses:
```java
for (Class<?> type = filterType; type != null; type = type.getSuperclass()) {
Integer order = table.get(type.getName());
if (order != null) return order;
}
return null;
```
That is why `http.addFilter(myFilter)` — the single-argument form, with no anchor — works at all.
It only works if some ancestor of your filter is in the table. A subclass of
`UsernamePasswordAuthenticationFilter` inherits slot 2100 and needs no anchor. A subclass of
`AbstractPreAuthenticatedProcessingFilter` inherits 1700.
`OncePerRequestFilter` is **not** in the table, which is what most people extend. So:
```java
http.addFilter(new RequestIdFilter());
// IllegalArgumentException: The Filter class com.ankurm.chain.filter.RequestIdFilter does not
// have a registered order and cannot be added without a specified order. Consider using
// addFilterBefore or addFilterAfter instead.
```
(Message read out of `HttpSecurity`'s constant pool with `javap -v`, not paraphrased.)
`addFilterBefore` and `addFilterAfter` raise the shorter sibling of that message,
`The Filter class … does not have a registered order`, when the **anchor** is unknown. Use the
two-argument form and name an anchor that is in the table.
---
[← 02 · The default chain](02-the-default-chain.md) · **03 · The order table** · [04 · Where custom filters land →](04-where-custom-filters-land.md)

View File

@@ -0,0 +1,112 @@
[← 03 · The order table](03-the-order-table.md) · **04 · Where custom filters land** · [05 · Failure modes →](05-failure-modes.md)
# 04 · Where custom filters land
`HttpSecurity` gives you four ways to add a filter. Three of them take an anchor:
| Call | Order your filter gets |
|---|---|
| `addFilterBefore(f, Anchor.class)` | `order(Anchor) - 1` |
| `addFilterAt(f, Anchor.class)` | `order(Anchor)` |
| `addFilterAfter(f, Anchor.class)` | `order(Anchor) + 1` |
| `addFilter(f)` | `order(f.getClass())`, resolved up the superclass chain |
All three anchored forms go through one private method:
```java
private HttpSecurity addFilterAtOffsetOf(Filter filter, int offset, Class<? extends Filter> registeredFilter) {
Integer registeredFilterOrder = this.filterOrders.getOrder(registeredFilter);
if (registeredFilterOrder == null) { throw new IllegalArgumentException(); }
int order = registeredFilterOrder + offset;
this.filters.add(new OrderedFilter(filter, order));
this.filterOrders.put(filter.getClass(), order); // <- your filter is now an anchor too
return this;
}
```
Four things follow from those seven lines, and none of them are documented.
## 1 · The offset is exactly ±1, not "the next free slot"
`addFilterBefore(f, CsrfFilter.class)` gives **1099**. It does not give 1050, or "somewhere
between `HeaderWriterFilter` and `CsrfFilter`". Pinned by
`FilterOrderTableTest.offsetsAreExactlyOne`.
## 2 · Your filter becomes an anchor
The last-but-one line registers *your* class at the order it just computed. So this works:
```java
http.addFilterAfter(new ApiKeyFilter(), LogoutFilter.class) // 1201
.addFilterAfter(new AuditFilter(), ApiKeyFilter.class); // 1202
```
And so does the accident: add two instances of the same class against different anchors and the
second `put` overwrites the first in the table. The chain is still correct — the `OrderedFilter`
wrappers already hold their own numbers — but any *later* `addFilterBefore(x, ThatClass.class)`
resolves against whichever registration happened last.
## 3 · The anchor does not have to be in the chain
`getOrder` consults the static table, not the chain being built. The `tie` profile calls
`csrf().disable()` and then still adds filters *before* `CsrfFilter.class`:
```
=== chain 1/12 (profile: tie)
4/12 HeaderWriterFilter order=900
5/12 MarkerFilterA order=? (not in the registration table)
6/12 MarkerFilterB order=? (not in the registration table)
7/12 LogoutFilter order=1200
```
There is no `CsrfFilter`. The markers still land on 1099, between 900 and 1200. Asserted by
`FilterChainContractTest.Tie.theAnchorNeedNotBePresent`. This is convenient — you can anchor to
a filter you have disabled — and it is a trap, because "before the CSRF filter" stops meaning
anything if someone later removes it and moves the surrounding filters.
## 4 · Two filters on the same anchor get the same number
`addFilterBefore(a, CsrfFilter.class)` and `addFilterBefore(b, CsrfFilter.class)` both produce
1099. `performBuild()` sorts with
```java
this.filters.sort(OrderComparator.INSTANCE);
```
`List.sort` on an `ArrayList` is a stable sort, so equal orders keep insertion order — the order
of your `addFilterBefore` calls. [`demo8-tie.txt`](output/demo8-tie.txt) shows both directions:
```
--- A registered first ---
executed order: [A, B]
--- B registered first (-DTIE_REVERSED=true), nothing else changed ---
executed order: [B, A]
```
That guarantee comes from `java.util.List`, not from Spring Security. Nothing in the API
promises it. If the relative order of two custom filters matters, anchor the second to the
first rather than relying on it.
## Where to put what
The four events from [02](02-the-default-chain.md#the-four-events) give the rule:
| Your filter is… | Anchor it after | Because by then |
|---|---|---|
| exploit protection (headers, tenancy, rate limits) | `SecurityContextHolderFilter` (701) | the context is available |
| authentication | `LogoutFilter` (1201) | headers and CSRF have run, nothing has authenticated yet |
| authorization, or anything that throws `AccessDeniedException` | `ExceptionTranslationFilter` (4001) | the principal is settled **and the throw will be translated** |
That last row differs from the reference documentation, which suggests
`AnonymousAuthenticationFilter` (3701) for authorization filters. 3701 is below
`ExceptionTranslationFilter` at 4000, so an `AccessDeniedException` thrown there is not
translated. See [05 · Failure modes](05-failure-modes.md#a-denial-that-comes-back-as-500).
The `custom` profile puts one filter at each of those three placements plus the documented one,
and [`demo5-custom-placement.txt`](output/demo5-custom-placement.txt) shows all four in the
live chain.
---
[← 03 · The order table](03-the-order-table.md) · **04 · Where custom filters land** · [05 · Failure modes →](05-failure-modes.md)

View File

@@ -0,0 +1,166 @@
[← 04 · Where custom filters land](04-where-custom-filters-land.md) · **05 · Failure modes** · [06 · Reading the TRACE output →](06-reading-the-trace.md)
# 05 · Failure modes
Six ways a filter chain goes wrong. Every one of them is reproducible in this module and every
transcript below is a real run.
## An authentication filter after `AuthorizationFilter`
Profile `misordered`, transcript [`demo7-misordered.txt`](output/demo7-misordered.txt).
The same `ApiKeyAuthenticationFilter` as the working `custom` profile, anchored to
`AuthorizationFilter` instead of `LogoutFilter` — order 4201 instead of 1201:
```
11/12 AuthorizationFilter order=4200
12/12 ApiKeyAuthenticationFilter order=? (not in the registration table)
$ curl -i -H "X-Api-Key: let-me-in" localhost:8080/whoami
HTTP/1.1 401
```
The key is valid. The filter is in the chain and demonstrably executes — it is the last thing
that runs. It sets the `SecurityContext` half a millisecond after the authorization decision was
already taken against the anonymous principal.
**Fingerprint:** a 401 or 403 with correct credentials, no exception, and TRACE showing your
filter running *after* `AuthorizationFilter (n/n)`. Anchor authentication filters to
`LogoutFilter`.
## A denial that comes back as 500
Profile `custom`, transcript [`demo6-exception-translation.txt`](output/demo6-exception-translation.txt).
`ExceptionTranslationFilter` (4000) is what turns `AccessDeniedException` into a 403 and
`AuthenticationException` into a 401 or a redirect. It does that by wrapping the *rest of the
chain* in a try/catch. A filter that throws from **below** 4000 is not inside that try block:
```
$ curl -i -u alice:password localhost:8080/tenant/doc # filter at order 3701
HTTP/1.1 500
$ curl -i -u alice:password localhost:8080/tenant/translated # filter at order 4001
HTTP/1.1 403
```
Identical filter code. Identical exception. 300 order slots apart.
3701 is the placement the reference documentation's own `TenantFilter` example uses
(`addFilterAfter(new TenantFilter(), AnonymousAuthenticationFilter.class)`), and the example
throws `AccessDeniedException`. If you follow it literally you get a 500.
**Fingerprint:** a stack trace in the container log with `AccessDeniedException` at the top and
no Spring Security frames below your own filter.
## The second instance of one `OncePerRequestFilter` subclass never runs
This one cost a test failure to find, and it is the reason the transcript above needs a JVM flag.
`OncePerRequestFilter` guards against double execution with a request attribute named
`getFilterName() + ".FILTERED"`. For a filter that is not a Spring bean, `getFilterName()` falls
back to the **class name**. Two instances of the same subclass therefore answer the same
attribute name, and the second one to run finds it already set and skips itself entirely:
```
$ curl -i -u alice:password localhost:8080/tenant/translated # filter at order 4001
HTTP/1.1 200 <- with both TenantFilters sharing one key
HTTP/1.1 403 <- with -DUNIQUE_ONCE_KEY=true
```
Both filters are in the chain either way — [`demo5-custom-placement.txt`](output/demo5-custom-placement.txt)
lists `TenantFilter` at positions 17 and 19. One of them is inert. There is no log line for it
at any level.
**Fingerprint:** a filter that is visibly in the chain and visibly does nothing. Override
`getAlreadyFilteredAttributeName()`, or give each instance its own class, or register them as
beans with distinct names.
## A custom filter that runs twice
Profile `doublereg`, transcript [`demo9-double-registration.txt`](output/demo9-double-registration.txt).
Spring Boot registers every `jakarta.servlet.Filter` **bean** with the servlet container. Add
the same bean to the security chain and it is in two chains at once:
```
--- profile: doublereg ---
X-Counting-Filter-Invocations: 2
countingFilter com.ankurm.chain.filter.CountingFilter urls=[/*]
--- profile: doublereg,fixed (FilterRegistrationBean.setEnabled(false)) ---
X-Counting-Filter-Invocations: 1
(not registered with the container)
```
Extending `OncePerRequestFilter` **hides** this rather than fixing it: the second pass
short-circuits, the counter reads 1, and you conclude there is no problem. The filter is still
registered twice, still wraps every request the container serves — including paths no
`SecurityFilterChain` matches, and including the internal `/error` dispatch.
**Fix:** declare a `FilterRegistrationBean` for it with `setEnabled(false)`, or do not make the
filter a bean at all and construct it inline in the `SecurityFilterChain` method.
## Two chains where the broad one shadows the narrow one
Profile `multichain`, transcript [`demo10-multichain.txt`](output/demo10-multichain.txt).
`FilterChainProxy` invokes the **first** matching chain and stops. A chain matching `any request`
declared ahead of a chain matching `/api/**` makes the second one dead code.
`WebSecurityFilterChainValidator` catches the blatant version of this at startup and throws
`UnreachableFilterChainException`:
> A filter chain that matches any request [`…`] has already been configured, which means that
> this filter chain [`…`] will never get invoked. Please use `HttpSecurity#securityMatcher` to
> ensure that there is only one filter chain configured for 'any request' and that the 'any
> request' filter chain is published last.
(Message read out of the class's constant pool. The validator and the exception are present in
both 6.5.1 and 7.1.1, so this is not a 7.x change.) It runs three checks —
`checkForAnyRequestRequestMatcher`, `checkForDuplicateMatchers`, `checkAuthorizationFilters`
and none of them catch the common case: two *specific* matchers that overlap partially, where
the broader one is declared first. Nothing warns about that.
Order chains explicitly with `@Order`, narrowest first. A bean with no `@Order` gets
`Ordered.LOWEST_PRECEDENCE`, and two of those have no defined relative order at all.
## `ignoring()` where you meant `permitAll()`
Profile `ignoring`, transcript [`demo11-ignoring-vs-permitall.txt`](output/demo11-ignoring-vs-permitall.txt).
```
=== chain 1/2 matcher = PathPattern [/static/**] (0 filters)
<no filters> - this chain does NOTHING.
```
A `SecurityFilterChain` with zero filters is a real, matched chain that runs nothing. The
response proves it:
```
$ curl -sD- -o /dev/null localhost:8080/static/asset.txt
HTTP/1.1 200
<- no security headers at all
$ curl -sD- -o /dev/null localhost:8080/public/hello
HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
```
Spring Security itself warns about this at startup:
```
WARN o.s.s.c.a.web.builders.WebSecurity : You are asking Spring Security to ignore
PathPattern [/static/**]. This is not recommended -- please use permitAll via
HttpSecurity#authorizeHttpRequests instead.
```
`ignoring()` is worth it only when the path genuinely serves static bytes and the header cost
matters. Anything behind it is outside Spring Security, including a path traversal that resolves
somewhere interesting.
---
[← 04 · Where custom filters land](04-where-custom-filters-land.md) · **05 · Failure modes** · [06 · Reading the TRACE output →](06-reading-the-trace.md)

View File

@@ -0,0 +1,163 @@
[← 05 · Failure modes](05-failure-modes.md) · **06 · Reading the TRACE output** · [07 · Multiple chains →](07-multiple-chains.md)
# 06 · Reading the TRACE output
```properties
logging.level.org.springframework.security=TRACE
```
That one line is worth more than every diagram of the filter chain, including the ones in this
repository. It is also actively misleading in three specific ways, and this chapter is mostly
about those.
The full transcripts are [`demo3-trace-authenticated.txt`](output/demo3-trace-authenticated.txt)
(a request that succeeds) and [`demo4-trace-csrf-403.txt`](output/demo4-trace-csrf-403.txt)
(one that does not).
## The five lines that matter
**Chain selection.** Before anything runs:
```
TRACE FilterChainProxy : Trying to match request against DefaultSecurityFilterChain defined as
'baseline' in [class path resource [com/ankurm/chain/config/BaselineSecurityConfig.class]]
matching [any request] and having filters [DisableEncodeUrl, WebAsyncManagerIntegration, …] (1/1)
```
This is the single most useful line in the file and almost nobody knows it exists. It names the
**bean** and the **class that declared it**. If you have four `SecurityFilterChain` beans and a
request is behaving as though it hit the wrong one, this line settles it in one grep. The
trailing `(1/1)` is the chain's position in `FilterChainProxy`'s list.
**Entry.** `DEBUG FilterChainProxy : Securing GET /whoami`
**Each filter.** `TRACE FilterChainProxy : Invoking CsrfFilter (5/16)`
The `(n/m)` counters are positions in the same list `/diag/chains` prints, so the two line up
exactly. A filter that never appears here is not in the chain.
**Exit.** `DEBUG FilterChainProxy : Secured GET /whoami` — meaning the chain completed and
handed off to the servlet. It does **not** mean the request succeeded.
**Startup.** Once per chain, at DEBUG, on `DefaultSecurityFilterChain`:
```
DEBUG o.s.s.web.DefaultSecurityFilterChain : Will secure any request with filters:
DisableEncodeUrlFilter, WebAsyncManagerIntegrationFilter, …
```
Note the wording: `with filters: …`, not `with [ … ]`. The bracketed form appears in the
reference documentation and in most blog posts; it is not what 7.1.1 prints, and it is not what
6.5.1 printed either.
## Misleading thing 1 · the log is not a timeline
Look at the end of the successful request:
```
TRACE FilterChainProxy : Invoking AnonymousAuthenticationFilter (14/16)
TRACE FilterChainProxy : Invoking ExceptionTranslationFilter (15/16)
TRACE FilterChainProxy : Invoking AuthorizationFilter (16/16)
TRACE RequestMatcherDelegatingAuthorizationManager : Authorizing GET /whoami
TRACE RequestMatcherDelegatingAuthorizationManager : Checking authorization on GET /whoami …
TRACE AnonymousAuthenticationFilter : Did not set SecurityContextHolder since already authenticated …
```
`AnonymousAuthenticationFilter` logs its decision **two filters after it was invoked**. It is not
a threading artefact. The filter installs a lazily-resolved `Supplier<SecurityContext>` and the
supplier only runs when something asks for the authentication — which, here, is
`AuthorizationFilter` at 16/16. The log line fires at resolution time.
The same effect appears earlier and matters more:
```
TRACE FilterChainProxy : Invoking SecurityContextHolderFilter (3/16)
TRACE FilterChainProxy : Invoking BasicAuthenticationFilter (11/16)
TRACE BasicAuthenticationFilter : Found username 'alice' in Basic Authorization header
TRACE HttpSessionSecurityContextRepository : No HttpSession currently exists
TRACE SupplierDeferredSecurityContext : Created SecurityContextImpl [Null authentication]
```
The session lookup is logged under filter **11**, not filter 3. `SecurityContextHolderFilter`
installed the supplier; `BasicAuthenticationFilter` was the first code to dereference it. If you
are reading the log to work out *when* the session was touched, filter 3 is the wrong answer.
## Misleading thing 2 · `SupplierDeferredSecurityContext : Created …` appears twice
Every request logs that line two or three times. It is not two contexts and it is not a bug —
`SupplierDeferredSecurityContext` logs on each `getContext()` that finds nothing stored. Do not
read it as evidence that something is creating contexts repeatedly.
## Misleading thing 3 · the rejected request runs the chain twice
The 403 transcript is the important one. After `CsrfFilter` rejects:
```
DEBUG CsrfFilter : Invalid CSRF token found for http://localhost:8080/hello
DEBUG AccessDeniedHandlerImpl : Responding with 403 status code
DEBUG o.a.c.c.C.[Tomcat].[localhost] : Processing ErrorPage[errorCode=0, location=/error]
TRACE FilterChainProxy : Trying to match request against DefaultSecurityFilterChain defined as 'baseline' …
DEBUG FilterChainProxy : Securing GET /error
TRACE FilterChainProxy : Invoking DisableEncodeUrlFilter (1/16)
TRACE FilterChainProxy : Invoking AuthorizationFilter (16/16)
TRACE RequestMatcherDelegatingAuthorizationManager : Authorizing GET /error
```
The container dispatches to `/error`, and the **entire sixteen-filter chain runs again** for that
dispatch. Two things about the second pass:
- **Half the filters invoke and then do nothing.** `OncePerRequestFilter.doFilter` starts with
`skipDispatch(request)`, which returns `true` when the request carries the
`jakarta.servlet.error.request_uri` attribute and `shouldNotFilterErrorDispatch()` says so —
and that method returns `true` by default. Of the sixteen default filters, six extend
`OncePerRequestFilter` (`DisableEncodeUrlFilter`, `WebAsyncManagerIntegrationFilter`,
`HeaderWriterFilter`, `CsrfFilter`, `DefaultLogoutPageGeneratingFilter`,
`BasicAuthenticationFilter`) and pass straight through. The other ten extend
`GenericFilterBean` and run in full. You can see it in the transcript: on the second pass
`LogoutFilter` still logs `Did not match request to PathPattern [POST /logout]`, while
`BasicAuthenticationFilter (11/16)` logs nothing at all.
- **So the error dispatch is authorized, but not authenticated.** `AuthorizationFilter` extends
`GenericFilterBean` and runs; `BasicAuthenticationFilter` extends `OncePerRequestFilter` and
does not. `GET /error` is therefore matched against `authorizeHttpRequests` on its own merits
as an **anonymous** request, and under `.anyRequest().authenticated()` it is denied. The trace
ends with `ExceptionTranslationFilter : Sending AnonymousAuthenticationToken … to
authentication entry point since access is denied` and an `AuthorizationDeniedException`
stack trace.
- The `(1/16) … (16/16)` counters restart, so a casual read of the log looks like the original
request ran twice.
This is the source of a whole family of confusing reports: a 403 that logs a 401 underneath it,
an `/error` page that itself 403s, a custom filter that "runs twice" for failed requests only.
Permitting `/error` explicitly is the usual fix, and it is worth doing deliberately rather than
discovering it.
## Stack traces are twice as tall as you expect
```
at org.springframework.security.web.access.intercept.AuthorizationFilter.doFilter(AuthorizationFilter.java:99)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(…)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(…)
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(…)
at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(…)
```
Three decorator frames between every pair of real filters. `FilterChainProxy` decorates the chain
with `ObservationFilterChainDecorator` whenever a usable `ObservationRegistry` bean exists — which
in Boot means whenever the actuator starter is present. Without it you get the plain
`VirtualFilterChain` and shorter traces. Nothing is wrong; the frames are just noise, and knowing
they are noise makes the trace readable.
## What to grep
```bash
grep 'Securing\|Secured' app.log # request boundaries
grep 'Trying to match request against' app.log # which chain, and which bean declared it
grep 'Invoking' app.log | tail -30 # the chain as it actually ran
grep -A2 'Invalid CSRF\|Access is denied\|Failed to' app.log
```
---
[← 05 · Failure modes](05-failure-modes.md) · **06 · Reading the TRACE output** · [07 · Multiple chains →](07-multiple-chains.md)

View File

@@ -0,0 +1,71 @@
[← 06 · Reading the TRACE output](06-reading-the-trace.md) · **07 · Multiple chains** · [08 · Debugging recipes →](08-debugging-recipes.md)
# 07 · Multiple chains
`FilterChainProxy` holds a list. It walks it in order, asks each chain `matches(request)`, and
invokes **the first one that says yes**. There is no fall-through and no combining.
The `multichain` profile declares three, and
[`demo10-multichain.txt`](output/demo10-multichain.txt) shows what they cost:
| Chain | `@Order` | Matcher | Filters |
|---|---|---|---|
| API | 1 | `/api/**` | 12 |
| Diagnostics / public | 2 | `/diag/**`, `/public/**` | 10 |
| Browser | 3 | any request | 15 |
## Shorter is the point
The API chain drops `CsrfFilter`, `UsernamePasswordAuthenticationFilter`,
`DefaultResourcesFilter`, `DefaultLoginPageGeneratingFilter` and
`DefaultLogoutPageGeneratingFilter` — five filters that have no meaning for a token client and
five filters that cannot surprise you at three in the morning. A stateless API served by the
browser chain is the origin of most "why is my POST getting a 403" questions.
One counter-intuitive detail: asking for `SessionCreationPolicy.STATELESS` **adds** a filter.
`SessionManagementFilter` (3900) is absent from the default chain and appears only when you
configure `sessionManagement(…)` at all. Statelessness is enforced by a filter, not by an absence
of one.
## Ordering
`@Order` on the bean fixes evaluation order. Narrowest matcher first; the `any request` chain
last. A bean with no `@Order` gets `Ordered.LOWEST_PRECEDENCE`, and two such beans have no
defined order relative to each other — which makes the behaviour depend on bean-definition
ordering, which depends on classpath scanning order.
`WebSecurityFilterChainValidator` catches the blatant failure at startup and throws
`UnreachableFilterChainException` when an `any request` chain is declared before another chain.
It does not catch two specific matchers that partially overlap. See
[05 · Failure modes](05-failure-modes.md#two-chains-where-the-broad-one-shadows-the-narrow-one).
## `securityMatcher` is not `requestMatchers`
They read similarly and do different jobs, and mixing them up produces a chain that is either
inert or catches everything:
- **`http.securityMatcher(…)`** — decides whether **this chain** handles the request at all. It
is the chain's `RequestMatcher`, evaluated by `FilterChainProxy` before any filter runs.
- **`authorizeHttpRequests(a -> a.requestMatchers(…)…)`** — decides what
`AuthorizationFilter` does **once the chain is already running**.
A chain with no `securityMatcher` matches any request. Two of those and the second is dead.
## `ignoring()` versus `permitAll()`
[`demo11-ignoring-vs-permitall.txt`](output/demo11-ignoring-vs-permitall.txt) puts them side by
side. `WebSecurity.ignoring()` produces a genuine `SecurityFilterChain` with **zero filters**:
```
=== chain 1/2 matcher = PathPattern [/static/**] (0 filters)
<no filters> - this chain does NOTHING.
```
and the response carries no `X-Content-Type-Options`, no cache headers, nothing. `permitAll()`
runs all sixteen filters and then authorizes. Spring Security warns about `ignoring()` at
startup and the warning is worth heeding: the saving is a few microseconds, and the cost is that
the path is outside Spring Security entirely — including the `HttpFirewall`.
---
[← 06 · Reading the TRACE output](06-reading-the-trace.md) · **07 · Multiple chains** · [08 · Debugging recipes →](08-debugging-recipes.md)

View File

@@ -0,0 +1,101 @@
[← 07 · Multiple chains](07-multiple-chains.md) · **08 · Debugging recipes** · [09 · Testing the chain →](09-testing-the-chain.md)
# 08 · Debugging recipes
Ordered by how often they settle the question.
## 1 · Print the live chain
[`DiagnosticsController`](../src/main/java/com/ankurm/chain/web/DiagnosticsController.java) is
about forty lines and answers most filter questions outright:
```java
@GetMapping(value = "/diag/chains", produces = "text/plain")
public String chains() {
for (SecurityFilterChain chain : this.filterChainProxy.getFilterChains()) {
// chain.getFilters() is the real, ordered list
}
}
```
It reads the live `FilterChainProxy` bean, so it cannot disagree with reality the way a diagram
can. Copy it into any application you are debugging.
**Delete it before shipping.** It publishes your entire security topology, including which paths
have which chains and how many filters each one has.
## 2 · Turn on TRACE and read the chain-selection line
```properties
logging.level.org.springframework.security=TRACE
```
```bash
grep 'Trying to match request against' app.log
```
That line names the bean and the class that declared the chain that served the request. When a
request is behaving as though a different chain handled it, this is the fastest way to find out
that it did. [06 · Reading the TRACE output](06-reading-the-trace.md) covers the rest, including
three ways the log misleads.
## 3 · Read the startup line before you read anything else
```bash
grep 'Will secure' app.log
```
One line per chain, printed at DEBUG on `DefaultSecurityFilterChain`, listing every filter in
order. If a filter you added is not in that line, nothing else you investigate matters.
## 4 · Ask what order a filter would get
```java
FilterOrderTable.orderOf(CsrfFilter.class); // 1100
FilterOrderTable.orderIfAddedBefore(CsrfFilter.class); // 1099
```
[`FilterOrderTable`](../src/main/java/com/ankurm/chain/support/FilterOrderTable.java) reflects
the real `FilterOrderRegistration`. `GET /diag/order` prints the whole table, marking slots whose
class is not on the classpath. Useful when you are choosing an anchor and want to know what sits
between it and the next real filter.
## 5 · Check for double registration
```bash
curl -s localhost:8080/diag/servlet-filters
```
Any custom filter that appears in both this list and `/diag/chains` runs twice.
[05 · Failure modes](05-failure-modes.md#a-custom-filter-that-runs-twice) has the fix.
## 6 · When a filter is in the chain and does nothing
Two candidates, in order of likelihood:
1. It extends `OncePerRequestFilter` and something else with the same
`getAlreadyFilteredAttributeName()` already ran — usually another instance of the same class.
2. It extends `OncePerRequestFilter` and the request is an `ERROR` or `ASYNC` dispatch, where
`shouldNotFilterErrorDispatch()` / `shouldNotFilterAsyncDispatch()` default to skipping.
Neither logs anything at any level. Put a `System.out.println` at the top of `doFilterInternal`
before concluding the filter is not in the chain — because it is.
## 7 · The symptom table
| Symptom | Look at |
|---|---|
| 401/403 with correct credentials | Is your auth filter after `AuthorizationFilter`? [05](05-failure-modes.md#an-authentication-filter-after-authorizationfilter) |
| 500 where you expected 403 | Are you throwing below `ExceptionTranslationFilter` (4000)? [05](05-failure-modes.md#a-denial-that-comes-back-as-500) |
| Filter runs twice | Is it a `@Bean`? [05](05-failure-modes.md#a-custom-filter-that-runs-twice) |
| Filter never runs, but is in the chain | `OncePerRequestFilter` key collision. [05](05-failure-modes.md#the-second-instance-of-one-onceperrequestfilter-subclass-never-runs) |
| Log seems to show the request twice | The `/error` dispatch. [06](06-reading-the-trace.md#misleading-thing-3--the-rejected-request-runs-the-chain-twice) |
| A path gets no security headers | `ignoring()`. [07](07-multiple-chains.md#ignoring-versus-permitall) |
| The wrong chain handled it | `grep 'Trying to match request against'` |
| `RequestRejectedException` from nowhere | `HttpFirewall`, before any chain. [01](01-the-two-proxies.md#filterchainproxy) |
| Two custom filters in the wrong order | Same anchor, same order number. [04](04-where-custom-filters-land.md#4--two-filters-on-the-same-anchor-get-the-same-number) |
| `FilterSecurityInterceptor` will not compile | Removed in 7.0; use `AuthorizationFilter`. [03](03-the-order-table.md#two-slots-name-classes-that-no-longer-exist) |
---
[← 07 · Multiple chains](07-multiple-chains.md) · **08 · Debugging recipes** · [09 · Testing the chain →](09-testing-the-chain.md)

View File

@@ -0,0 +1,83 @@
[← 08 · Debugging recipes](08-debugging-recipes.md) · **09 · Testing the chain** · [docs index →](README.md)
# 09 · Testing the chain
Twenty-one assertions, in
[`FilterOrderTableTest`](../src/test/java/com/ankurm/chain/FilterOrderTableTest.java) and
[`FilterChainContractTest`](../src/test/java/com/ankurm/chain/FilterChainContractTest.java).
Output in [`docs/output/tests.txt`](output/tests.txt).
## Assert the chain, not the happy path
```java
@Autowired FilterChainProxy proxy;
assertThat(proxy.getFilterChains().get(0).getFilters().stream()
.map((filter) -> filter.getClass().getSimpleName()).toList())
.containsExactly("DisableEncodeUrlFilter", , "AuthorizationFilter");
```
`containsExactly` on the whole list is deliberately brittle. A Spring Security upgrade that adds
or removes a filter should fail this test loudly rather than change behaviour quietly — that is
what the test is for. `containsSubsequence` is the right tool for a claim about *relative*
placement:
```java
assertThat(names).containsSubsequence("LogoutFilter", "ApiKeyAuthenticationFilter",
"UsernamePasswordAuthenticationFilter");
```
## One nested class per profile
Profiles are fixed at context startup, so a scenario is a `@Nested` class with its own
`@ActiveProfiles`. Spring's test context caching means the seven contexts in this suite are each
built once and the whole run takes about eight seconds.
```java
@Nested @SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("misordered")
class Misordered { }
```
Boot 4 moved the test slices: `@AutoConfigureMockMvc` is
`org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc` and lives in
`spring-boot-starter-webmvc-test`; the security post-processors need
`spring-boot-starter-security-test`. `spring-boot-starter-test` alone is not enough.
## Assert the number, not the position
```java
assertThat(FilterOrderTable.orderIfAddedBefore(CsrfFilter.class)).isEqualTo(1099);
```
Position in the chain moves whenever anything else is added. The order *number* is a property of
the framework and only moves on an upgrade — which is exactly when you want to be told.
## Assert what is absent
```java
assertThat(names).doesNotContain("SessionManagementFilter", "SecurityContextPersistenceFilter");
assertThat(FilterOrderTable.isOnClasspath(
"org.springframework.security.web.access.intercept.FilterSecurityInterceptor")).isFalse();
```
Half the useful facts about the default chain are negative ones.
## MockMvc shows you the throw, not the status
The 500 in [`demo6`](output/demo6-exception-translation.txt) has no MockMvc equivalent, because
MockMvc has no container to render an error page. It rethrows instead, which is the same fact
from the other side:
```java
assertThat(catchThrowable(() -> mvc.perform(get("/tenant/doc").with(user()))))
.isInstanceOf(AccessDeniedException.class)
.hasMessageContaining("doc-placement");
```
Note `isInstanceOf`, not `rootCause().isInstanceOf` — the exception arrives with no cause, having
been thrown by a filter and never wrapped. Getting that wrong is what surfaced the finding in
[05](05-failure-modes.md#the-second-instance-of-one-onceperrequestfilter-subclass-never-runs).
---
[← 08 · Debugging recipes](08-debugging-recipes.md) · **09 · Testing the chain** · [docs index →](README.md)

View File

@@ -0,0 +1,39 @@
# Documentation index — `filter-chain`
Nine chapters, in order. Each links to the next; each links to the source files and the captured
output it draws on. Nothing here is asserted without a file behind it.
| # | Chapter | What it answers |
|---|---|---|
| 01 | [The two proxies](01-the-two-proxies.md) | Why Spring Security is one servlet filter, and what `DelegatingFilterProxy`, `FilterChainProxy` and `SecurityFilterChain` each do |
| 02 | [The default chain](02-the-default-chain.md) | The sixteen filters of the reference configuration, one by one, and what breaks without each |
| 03 | [The order table](03-the-order-table.md) | `FilterOrderRegistration`: every slot from 100 to 4300, the two reserved gaps, and the two slots naming classes that no longer exist |
| 04 | [Where custom filters land](04-where-custom-filters-land.md) | `addFilterBefore` / `After` / `At` / `addFilter`, the ±1 offset, superclass resolution, and ties |
| 05 | [Failure modes](05-failure-modes.md) | Six ways a chain goes wrong, each reproducible here |
| 06 | [Reading the TRACE output](06-reading-the-trace.md) | The five lines that matter and the three ways the log misleads |
| 07 | [Multiple chains](07-multiple-chains.md) | Matching, ordering, `securityMatcher` vs `requestMatchers`, `ignoring()` vs `permitAll()` |
| 08 | [Debugging recipes](08-debugging-recipes.md) | Seven recipes and a symptom → chapter table |
| 09 | [Testing the chain](09-testing-the-chain.md) | How the twenty-one assertions are written and why |
## Captured output
Every file below is regenerated by [`../scripts/run-all.sh`](../scripts/run-all.sh) from a real
run. None of it is typed by hand.
| File | Scenario |
|---|---|
| [`demo1-order-table.txt`](output/demo1-order-table.txt) | `FilterOrderRegistration` reflected out of the jar |
| [`demo2-default-chain.txt`](output/demo2-default-chain.txt) | The live 16-filter chain, plus the startup DEBUG line |
| [`demo3-trace-authenticated.txt`](output/demo3-trace-authenticated.txt) | TRACE for one authenticated `GET` |
| [`demo4-trace-csrf-403.txt`](output/demo4-trace-csrf-403.txt) | TRACE for a CSRF rejection, including the `/error` re-dispatch |
| [`demo5-custom-placement.txt`](output/demo5-custom-placement.txt) | Four custom filters at four anchors |
| [`demo6-exception-translation.txt`](output/demo6-exception-translation.txt) | 500 vs 403 across the `ExceptionTranslationFilter` boundary |
| [`demo7-misordered.txt`](output/demo7-misordered.txt) | An authentication filter one slot too late |
| [`demo8-tie.txt`](output/demo8-tie.txt) | Two filters, one anchor, one order number |
| [`demo9-double-registration.txt`](output/demo9-double-registration.txt) | A filter bean in two chains at once |
| [`demo10-multichain.txt`](output/demo10-multichain.txt) | Three chains, three lengths |
| [`demo11-ignoring-vs-permitall.txt`](output/demo11-ignoring-vs-permitall.txt) | A chain with zero filters |
| [`demo12-servlet-filters.txt`](output/demo12-servlet-filters.txt) | What the servlet container sees |
| [`tests.txt`](output/tests.txt) | The 21 assertions |
[← back to the module README](../README.md)

View File

@@ -0,0 +1,52 @@
==============================================================================
docs/output/demo1-order-table.txt
FilterOrderRegistration, read out of spring-security-config 7.1.1 by reflection.
GET /diag/order
==============================================================================
FilterOrderRegistration, reflected out of spring-security-config.
Slots are 100 apart, starting at 100. addFilterBefore = slot-1, addFilterAfter = slot+1.
100 org.springframework.security.web.session.DisableEncodeUrlFilter
200 org.springframework.security.web.session.ForceEagerSessionCreationFilter
300 org.springframework.security.web.access.channel.ChannelProcessingFilter <-- CLASS NOT ON CLASSPATH
400 org.springframework.security.web.transport.HttpsRedirectFilter
500 -- reserved, nothing registered --
600 org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter
700 org.springframework.security.web.context.SecurityContextHolderFilter
800 org.springframework.security.web.context.SecurityContextPersistenceFilter
900 org.springframework.security.web.header.HeaderWriterFilter
1000 org.springframework.web.filter.CorsFilter
1100 org.springframework.security.web.csrf.CsrfFilter
1200 org.springframework.security.web.authentication.logout.LogoutFilter
1300 org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter <-- CLASS NOT ON CLASSPATH
1400 org.springframework.security.saml2.provider.service.web.Saml2WebSsoAuthenticationRequestFilter <-- CLASS NOT ON CLASSPATH
1500 org.springframework.security.web.authentication.ott.GenerateOneTimeTokenFilter
1600 org.springframework.security.web.authentication.preauth.x509.X509AuthenticationFilter
1700 org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter
1800 org.springframework.security.cas.web.CasAuthenticationFilter <-- CLASS NOT ON CLASSPATH
1900 org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter <-- CLASS NOT ON CLASSPATH
2000 org.springframework.security.saml2.provider.service.web.authentication.Saml2WebSsoAuthenticationFilter <-- CLASS NOT ON CLASSPATH
2100 org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
2200 org.springframework.security.web.authentication.ott.OneTimeTokenAuthenticationFilter
2300 -- reserved, nothing registered --
2400 org.springframework.security.web.authentication.ui.DefaultResourcesFilter
2500 org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter
2600 org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter
2700 org.springframework.security.web.authentication.ui.DefaultOneTimeTokenSubmitPageGeneratingFilter
2800 org.springframework.security.web.session.ConcurrentSessionFilter
2900 org.springframework.security.web.authentication.www.DigestAuthenticationFilter
3000 org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter <-- CLASS NOT ON CLASSPATH
3100 org.springframework.security.web.authentication.www.BasicAuthenticationFilter
3200 org.springframework.security.web.authentication.AuthenticationFilter
3300 org.springframework.security.web.savedrequest.RequestCacheAwareFilter
3400 org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter
3500 org.springframework.security.web.jaasapi.JaasApiIntegrationFilter
3600 org.springframework.security.web.authentication.rememberme.RememberMeAuthenticationFilter
3700 org.springframework.security.web.authentication.AnonymousAuthenticationFilter
3800 org.springframework.security.oauth2.client.web.OAuth2AuthorizationCodeGrantFilter <-- CLASS NOT ON CLASSPATH
3900 org.springframework.security.web.session.SessionManagementFilter
4000 org.springframework.security.web.access.ExceptionTranslationFilter
4100 org.springframework.security.web.access.intercept.FilterSecurityInterceptor <-- CLASS NOT ON CLASSPATH
4200 org.springframework.security.web.access.intercept.AuthorizationFilter
4300 org.springframework.security.web.authentication.switchuser.SwitchUserFilter

View File

@@ -0,0 +1,57 @@
==============================================================================
docs/output/demo10-multichain.txt
Three SecurityFilterChain beans. Evaluation stops at the first match.
GET /diag/chains (profile: multichain)
==============================================================================
FilterChainProxy holds 3 SecurityFilterChain(s).
The FIRST chain whose matcher accepts the request wins; the rest are never consulted.
=== chain 1/3 matcher = Or [PathPattern [/api/**]] (12 filters)
1/12 DisableEncodeUrlFilter order=100
2/12 WebAsyncManagerIntegrationFilter order=600
3/12 SecurityContextHolderFilter order=700
4/12 HeaderWriterFilter order=900
5/12 LogoutFilter order=1200
6/12 BasicAuthenticationFilter order=3100
7/12 RequestCacheAwareFilter order=3300
8/12 SecurityContextHolderAwareRequestFilter order=3400
9/12 AnonymousAuthenticationFilter order=3700
10/12 SessionManagementFilter order=3900
11/12 ExceptionTranslationFilter order=4000
12/12 AuthorizationFilter order=4200
=== chain 2/3 matcher = Or [PathPattern [/diag/**], PathPattern [/public/**]] (10 filters)
1/10 DisableEncodeUrlFilter order=100
2/10 WebAsyncManagerIntegrationFilter order=600
3/10 SecurityContextHolderFilter order=700
4/10 HeaderWriterFilter order=900
5/10 LogoutFilter order=1200
6/10 RequestCacheAwareFilter order=3300
7/10 SecurityContextHolderAwareRequestFilter order=3400
8/10 AnonymousAuthenticationFilter order=3700
9/10 ExceptionTranslationFilter order=4000
10/10 AuthorizationFilter order=4200
=== chain 3/3 matcher = any request (15 filters)
1/15 DisableEncodeUrlFilter order=100
2/15 WebAsyncManagerIntegrationFilter order=600
3/15 SecurityContextHolderFilter order=700
4/15 HeaderWriterFilter order=900
5/15 CsrfFilter order=1100
6/15 LogoutFilter order=1200
7/15 UsernamePasswordAuthenticationFilter order=2100
8/15 DefaultResourcesFilter order=2400
9/15 DefaultLoginPageGeneratingFilter order=2500
10/15 DefaultLogoutPageGeneratingFilter order=2600
11/15 RequestCacheAwareFilter order=3300
12/15 SecurityContextHolderAwareRequestFilter order=3400
13/15 AnonymousAuthenticationFilter order=3700
14/15 ExceptionTranslationFilter order=4000
15/15 AuthorizationFilter order=4200
--- which chain served what ---
$ curl -i -u alice:password localhost:8080/api/data
HTTP/1.1 200
$ curl -i localhost:8080/whoami # browser chain: redirect to the login page
HTTP/1.1 302

View File

@@ -0,0 +1,38 @@
==============================================================================
docs/output/demo11-ignoring-vs-permitall.txt
WebSecurity.ignoring() produces a real chain with ZERO filters.
GET /diag/chains (profile: ignoring)
==============================================================================
FilterChainProxy holds 2 SecurityFilterChain(s).
The FIRST chain whose matcher accepts the request wins; the rest are never consulted.
=== chain 1/2 matcher = PathPattern [/static/**] (0 filters)
<no filters> - this chain does NOTHING. Requests it matches are
unauthenticated, unauthorised, and get no security headers.
=== chain 2/2 matcher = any request (12 filters)
1/12 DisableEncodeUrlFilter order=100
2/12 WebAsyncManagerIntegrationFilter order=600
3/12 SecurityContextHolderFilter order=700
4/12 HeaderWriterFilter order=900
5/12 CsrfFilter order=1100
6/12 LogoutFilter order=1200
7/12 BasicAuthenticationFilter order=3100
8/12 RequestCacheAwareFilter order=3300
9/12 SecurityContextHolderAwareRequestFilter order=3400
10/12 AnonymousAuthenticationFilter order=3700
11/12 ExceptionTranslationFilter order=4000
12/12 AuthorizationFilter order=4200
--- response headers, ignored path vs permitAll path ---
$ curl -sD- -o /dev/null localhost:8080/static/asset.txt
HTTP/1.1 200
$ curl -sD- -o /dev/null localhost:8080/public/hello
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
Expires: 0

View File

@@ -0,0 +1,17 @@
==============================================================================
docs/output/demo12-servlet-filters.txt
What the servlet container has registered. FilterChainProxy is ONE entry here.
GET /diag/servlet-filters (profile: baseline)
==============================================================================
Filters registered with the servlet container:
Tomcat WebSocket (JSR356) Filter org.apache.tomcat.websocket.server.WsFilter urls=[/*]
characterEncodingFilter org.springframework.boot.servlet.filter.OrderedCharacterEncodingFilter urls=[/*]
formContentFilter org.springframework.boot.servlet.filter.OrderedFormContentFilter urls=[/*]
requestContextFilter org.springframework.boot.servlet.filter.OrderedRequestContextFilter urls=[/*]
springSecurityFilterChain org.springframework.boot.web.servlet.DelegatingFilterProxyRegistrationBean$1 urls=[/*]
webMvcObservationFilter org.springframework.web.filter.ServerHttpObservationFilter urls=[/*]
Anything listed here runs OUTSIDE FilterChainProxy. A custom security filter that
appears both here and in /diag/chains will run twice for every request.

View File

@@ -0,0 +1,29 @@
==============================================================================
docs/output/demo2-default-chain.txt
The chain FilterChainProxy actually holds for the reference configuration.
GET /diag/chains (profile: baseline)
==============================================================================
FilterChainProxy holds 1 SecurityFilterChain(s).
The FIRST chain whose matcher accepts the request wins; the rest are never consulted.
=== chain 1/1 matcher = any request (16 filters)
1/16 DisableEncodeUrlFilter order=100
2/16 WebAsyncManagerIntegrationFilter order=600
3/16 SecurityContextHolderFilter order=700
4/16 HeaderWriterFilter order=900
5/16 CsrfFilter order=1100
6/16 LogoutFilter order=1200
7/16 UsernamePasswordAuthenticationFilter order=2100
8/16 DefaultResourcesFilter order=2400
9/16 DefaultLoginPageGeneratingFilter order=2500
10/16 DefaultLogoutPageGeneratingFilter order=2600
11/16 BasicAuthenticationFilter order=3100
12/16 RequestCacheAwareFilter order=3300
13/16 SecurityContextHolderAwareRequestFilter order=3400
14/16 AnonymousAuthenticationFilter order=3700
15/16 ExceptionTranslationFilter order=4000
16/16 AuthorizationFilter order=4200
--- and the same list as Spring Security prints it at startup (DEBUG) ---

View File

@@ -0,0 +1,53 @@
==============================================================================
docs/output/demo3-trace-authenticated.txt
One authenticated GET, org.springframework.security at TRACE.
curl -u alice:password localhost:8080/whoami
Container and bean-factory lines removed; nothing else edited.
==============================================================================
DEBUG o.a.c.authenticator.AuthenticatorBase : Security checking request GET /whoami
DEBUG o.a.c.authenticator.AuthenticatorBase : Not subject to any constraint
TRACE o.s.security.web.FilterChainProxy : Trying to match request against DefaultSecurityFilterChain defined as 'baseline' in [class path resource [com/ankurm/chain/config/BaselineSecurityConfig.class]] matching [any request] and having filters [DisableEncodeUrl, WebAsyncManagerIntegration, SecurityContextHolder, HeaderWriter, Csrf, Logout, UsernamePasswordAuthentication, DefaultResources, DefaultLoginPageGenerating, DefaultLogoutPageGenerating, BasicAuthentication, RequestCacheAware, SecurityContextHolderAwareRequest, AnonymousAuthentication, ExceptionTranslation, Authorization] (1/1)
DEBUG o.s.security.web.FilterChainProxy : Securing GET /whoami
TRACE o.s.security.web.FilterChainProxy : Invoking DisableEncodeUrlFilter (1/16)
TRACE o.s.security.web.FilterChainProxy : Invoking WebAsyncManagerIntegrationFilter (2/16)
TRACE o.s.security.web.FilterChainProxy : Invoking SecurityContextHolderFilter (3/16)
TRACE o.s.security.web.FilterChainProxy : Invoking HeaderWriterFilter (4/16)
TRACE o.s.security.web.FilterChainProxy : Invoking CsrfFilter (5/16)
TRACE o.s.s.w.c.CsrfTokenRequestAttributeHandler : Wrote a CSRF token to the following request attributes: [_csrf, org.springframework.security.web.csrf.CsrfToken]
TRACE o.s.security.web.csrf.CsrfFilter : Did not protect against CSRF since request did not match IsNotHttpMethod [TRACE, HEAD, GET, OPTIONS]
TRACE o.s.security.web.FilterChainProxy : Invoking LogoutFilter (6/16)
TRACE o.s.s.w.a.logout.LogoutFilter : Did not match request to PathPattern [POST /logout]
TRACE o.s.security.web.FilterChainProxy : Invoking UsernamePasswordAuthenticationFilter (7/16)
TRACE o.s.s.w.a.UsernamePasswordAuthenticationFilter : Did not match request to PathPattern [POST /login]
TRACE o.s.security.web.FilterChainProxy : Invoking DefaultResourcesFilter (8/16)
TRACE o.s.security.web.FilterChainProxy : Invoking DefaultLoginPageGeneratingFilter (9/16)
TRACE o.s.security.web.FilterChainProxy : Invoking DefaultLogoutPageGeneratingFilter (10/16)
TRACE o.s.s.w.a.u.DefaultLogoutPageGeneratingFilter : Did not render default logout page since request did not match [PathPattern [GET /logout]]
TRACE o.s.security.web.FilterChainProxy : Invoking BasicAuthenticationFilter (11/16)
TRACE o.s.s.w.a.www.BasicAuthenticationFilter : Found username 'alice' in Basic Authorization header
TRACE o.s.s.w.c.HttpSessionSecurityContextRepository : No HttpSession currently exists
TRACE o.s.s.w.c.SupplierDeferredSecurityContext : Created SecurityContextImpl [Null authentication]
TRACE o.s.s.w.c.SupplierDeferredSecurityContext : Created SecurityContextImpl [Null authentication]
TRACE o.s.s.authentication.ProviderManager : Authenticating request with DaoAuthenticationProvider (1/1)
DEBUG o.s.s.a.dao.DaoAuthenticationProvider : Authenticated user
DEBUG o.s.s.w.a.www.BasicAuthenticationFilter : Set SecurityContextHolder to UsernamePasswordAuthenticationToken [Principal=org.springframework.security.core.userdetails.User [Username=alice, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, CredentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[ROLE_USER]], Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=127.0.0.1, SessionId=null], Granted Authorities=[ROLE_USER, FactorGrantedAuthority [authority=FACTOR_PASSWORD, issuedAt=<timestamp>]]]
TRACE o.s.security.web.FilterChainProxy : Invoking RequestCacheAwareFilter (12/16)
TRACE o.s.s.w.s.HttpSessionRequestCache : matchingRequestParameterName is required for getMatchingRequest to lookup a value, but not provided
TRACE o.s.security.web.FilterChainProxy : Invoking SecurityContextHolderAwareRequestFilter (13/16)
TRACE o.s.security.web.FilterChainProxy : Invoking AnonymousAuthenticationFilter (14/16)
TRACE o.s.security.web.FilterChainProxy : Invoking ExceptionTranslationFilter (15/16)
TRACE o.s.security.web.FilterChainProxy : Invoking AuthorizationFilter (16/16)
TRACE o.s.s.w.a.i.RequestMatcherDelegatingAuthorizationManager : Authorizing GET /whoami
TRACE o.s.s.w.a.i.RequestMatcherDelegatingAuthorizationManager : Checking authorization on GET /whoami using org.springframework.security.authorization.AuthenticatedAuthorizationManager@<hash>
TRACE o.s.s.w.a.AnonymousAuthenticationFilter : Did not set SecurityContextHolder since already authenticated UsernamePasswordAuthenticationToken [Principal=org.springframework.security.core.userdetails.User [Username=alice, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, CredentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[ROLE_USER]], Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=127.0.0.1, SessionId=null], Granted Authorities=[ROLE_USER, FactorGrantedAuthority [authority=FACTOR_PASSWORD, issuedAt=<timestamp>]]]
DEBUG o.s.security.web.FilterChainProxy : Secured GET /whoami
TRACE o.s.web.servlet.DispatcherServlet : GET "/whoami", parameters={}, headers={masked} in DispatcherServlet 'dispatcherServlet'
TRACE o.s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to com.ankurm.chain.web.DemoController#whoami(HttpServletRequest)
TRACE o.s.web.method.HandlerMethod : Arguments: [SecurityContextHolderAwareRequestWrapper[ org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterRequest@<hash>]]
DEBUG o.s.w.s.m.m.a.RequestResponseBodyMethodProcessor : Using 'text/plain', given [*/*] and supported [text/plain]
TRACE o.s.w.s.m.m.a.RequestResponseBodyMethodProcessor : Writing ["principal=alice authenticated=true authorities=[ROLE_USER, FactorGrantedAuthority [authority=FACTOR_PASSWORD, issuedAt=<timestamp>]] type=UsernamePasswordAuthenticationToken counting-filter-invocations=null
"]
TRACE o.s.s.w.header.writers.HstsHeaderWriter : Not injecting HSTS header since it did not match request to [Is Secure]
TRACE o.s.web.servlet.DispatcherServlet : No view rendering, null ModelAndView returned.
DEBUG o.s.web.servlet.DispatcherServlet : Completed 200 OK, headers={masked}

View File

@@ -0,0 +1,144 @@
==============================================================================
docs/output/demo4-trace-csrf-403.txt
One POST with no CSRF token. Note WHICH filter rejects it and how far the request got.
curl -X POST -u alice:password localhost:8080/hello
==============================================================================
DEBUG o.a.c.authenticator.AuthenticatorBase : Security checking request POST /hello
DEBUG o.a.c.authenticator.AuthenticatorBase : Not subject to any constraint
TRACE o.s.security.web.FilterChainProxy : Trying to match request against DefaultSecurityFilterChain defined as 'baseline' in [class path resource [com/ankurm/chain/config/BaselineSecurityConfig.class]] matching [any request] and having filters [DisableEncodeUrl, WebAsyncManagerIntegration, SecurityContextHolder, HeaderWriter, Csrf, Logout, UsernamePasswordAuthentication, DefaultResources, DefaultLoginPageGenerating, DefaultLogoutPageGenerating, BasicAuthentication, RequestCacheAware, SecurityContextHolderAwareRequest, AnonymousAuthentication, ExceptionTranslation, Authorization] (1/1)
DEBUG o.s.security.web.FilterChainProxy : Securing POST /hello
TRACE o.s.security.web.FilterChainProxy : Invoking DisableEncodeUrlFilter (1/16)
TRACE o.s.security.web.FilterChainProxy : Invoking WebAsyncManagerIntegrationFilter (2/16)
TRACE o.s.security.web.FilterChainProxy : Invoking SecurityContextHolderFilter (3/16)
TRACE o.s.security.web.FilterChainProxy : Invoking HeaderWriterFilter (4/16)
TRACE o.s.security.web.FilterChainProxy : Invoking CsrfFilter (5/16)
TRACE o.s.s.w.c.CsrfTokenRequestAttributeHandler : Wrote a CSRF token to the following request attributes: [_csrf, org.springframework.security.web.csrf.CsrfToken]
TRACE o.s.s.web.csrf.CsrfTokenRequestHandler : Did not find a CSRF token in the [X-CSRF-TOKEN] request header
TRACE o.s.s.web.csrf.CsrfTokenRequestHandler : Did not find a CSRF token in the [_csrf] request parameter
DEBUG o.s.security.web.csrf.CsrfFilter : Invalid CSRF token found for http://localhost:8080/hello
DEBUG o.s.s.w.access.AccessDeniedHandlerImpl : Responding with 403 status code
TRACE o.s.s.w.header.writers.HstsHeaderWriter : Not injecting HSTS header since it did not match request to [Is Secure]
DEBUG o.a.c.c.C.[Tomcat].[localhost] : Processing ErrorPage[errorCode=0, location=/error]
TRACE o.s.security.web.FilterChainProxy : Trying to match request against DefaultSecurityFilterChain defined as 'baseline' in [class path resource [com/ankurm/chain/config/BaselineSecurityConfig.class]] matching [any request] and having filters [DisableEncodeUrl, WebAsyncManagerIntegration, SecurityContextHolder, HeaderWriter, Csrf, Logout, UsernamePasswordAuthentication, DefaultResources, DefaultLoginPageGenerating, DefaultLogoutPageGenerating, BasicAuthentication, RequestCacheAware, SecurityContextHolderAwareRequest, AnonymousAuthentication, ExceptionTranslation, Authorization] (1/1)
DEBUG o.s.security.web.FilterChainProxy : Securing GET /error
TRACE o.s.security.web.FilterChainProxy : Invoking DisableEncodeUrlFilter (1/16)
TRACE o.s.security.web.FilterChainProxy : Invoking WebAsyncManagerIntegrationFilter (2/16)
TRACE o.s.security.web.FilterChainProxy : Invoking SecurityContextHolderFilter (3/16)
TRACE o.s.security.web.FilterChainProxy : Invoking HeaderWriterFilter (4/16)
TRACE o.s.security.web.FilterChainProxy : Invoking CsrfFilter (5/16)
TRACE o.s.security.web.FilterChainProxy : Invoking LogoutFilter (6/16)
TRACE o.s.s.w.a.logout.LogoutFilter : Did not match request to PathPattern [POST /logout]
TRACE o.s.security.web.FilterChainProxy : Invoking UsernamePasswordAuthenticationFilter (7/16)
TRACE o.s.s.w.a.UsernamePasswordAuthenticationFilter : Did not match request to PathPattern [POST /login]
TRACE o.s.security.web.FilterChainProxy : Invoking DefaultResourcesFilter (8/16)
TRACE o.s.security.web.FilterChainProxy : Invoking DefaultLoginPageGeneratingFilter (9/16)
TRACE o.s.security.web.FilterChainProxy : Invoking DefaultLogoutPageGeneratingFilter (10/16)
TRACE o.s.security.web.FilterChainProxy : Invoking BasicAuthenticationFilter (11/16)
TRACE o.s.security.web.FilterChainProxy : Invoking RequestCacheAwareFilter (12/16)
TRACE o.s.s.w.s.HttpSessionRequestCache : matchingRequestParameterName is required for getMatchingRequest to lookup a value, but not provided
TRACE o.s.security.web.FilterChainProxy : Invoking SecurityContextHolderAwareRequestFilter (13/16)
TRACE o.s.security.web.FilterChainProxy : Invoking AnonymousAuthenticationFilter (14/16)
TRACE o.s.security.web.FilterChainProxy : Invoking ExceptionTranslationFilter (15/16)
TRACE o.s.security.web.FilterChainProxy : Invoking AuthorizationFilter (16/16)
TRACE o.s.s.w.a.i.RequestMatcherDelegatingAuthorizationManager : Authorizing GET /error
TRACE o.s.s.w.a.i.RequestMatcherDelegatingAuthorizationManager : Checking authorization on GET /error using org.springframework.security.authorization.AuthenticatedAuthorizationManager@<hash>
TRACE o.s.s.w.c.HttpSessionSecurityContextRepository : Did not find SecurityContext in HttpSession 6376951C7CF74BC73C543E50270726EA using the SPRING_SECURITY_CONTEXT session attribute
TRACE o.s.s.w.c.SupplierDeferredSecurityContext : Created SecurityContextImpl [Null authentication]
TRACE o.s.s.w.c.SupplierDeferredSecurityContext : Created SecurityContextImpl [Null authentication]
TRACE o.s.s.w.a.AnonymousAuthenticationFilter : Set SecurityContextHolder to AnonymousAuthenticationToken [Principal=anonymousUser, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=127.0.0.1, SessionId=6376951C7CF74BC73C543E50270726EA], Granted Authorities=[ROLE_ANONYMOUS]]
TRACE o.s.s.w.a.ExceptionTranslationFilter : Sending AnonymousAuthenticationToken [Principal=anonymousUser, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=127.0.0.1, SessionId=6376951C7CF74BC73C543E50270726EA], Granted Authorities=[ROLE_ANONYMOUS]] to authentication entry point since access is denied
org.springframework.security.authorization.AuthorizationDeniedException: Access Denied
at org.springframework.security.web.access.intercept.AuthorizationFilter.doFilter(AuthorizationFilter.java:99)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:243)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:230)
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:140)
at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:126)
at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:120)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:243)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:230)
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:140)
at org.springframework.security.web.authentication.AnonymousAuthenticationFilter.doFilter(AnonymousAuthenticationFilter.java:100)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:243)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:230)
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:140)
at org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter.doFilter(SecurityContextHolderAwareRequestFilter.java:181)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:243)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:230)
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:140)
at org.springframework.security.web.savedrequest.RequestCacheAwareFilter.doFilter(RequestCacheAwareFilter.java:63)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:243)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:230)
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:140)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:101)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:243)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:230)
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:140)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:101)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:243)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:230)
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:140)
at org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter.doFilter(DefaultLoginPageGeneratingFilter.java:246)
at org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter.doFilter(DefaultLoginPageGeneratingFilter.java:232)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:243)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:230)
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:140)
at org.springframework.security.web.authentication.ui.DefaultResourcesFilter.doFilter(DefaultResourcesFilter.java:73)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:243)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:230)
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:140)
at org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter.doFilter(AbstractAuthenticationProcessingFilter.java:245)
at org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter.doFilter(AbstractAuthenticationProcessingFilter.java:239)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:243)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:230)
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:140)
at org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:110)
at org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:96)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:243)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:230)
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:140)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:101)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:243)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:230)
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:140)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:101)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:243)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:230)
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:140)
at org.springframework.security.web.context.SecurityContextHolderFilter.doFilter(SecurityContextHolderFilter.java:82)
at org.springframework.security.web.context.SecurityContextHolderFilter.doFilter(SecurityContextHolderFilter.java:69)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:243)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:230)
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:140)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:101)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:243)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:230)
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:140)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:101)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:243)
at org.springframework.security.web.ObservationFilterChainDecorator$AroundFilterObservation$SimpleAroundFilterObservation.lambda$wrap$0(ObservationFilterChainDecorator.java:336)
at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:227)
at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:140)
at org.springframework.security.web.FilterChainProxy.doFilterInternal(FilterChainProxy.java:237)
at org.springframework.security.web.FilterChainProxy.doFilter(FilterChainProxy.java:195)
at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:113)
at org.springframework.web.filter.ServletRequestPathFilter.doFilter(ServletRequestPathFilter.java:52)
at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:113)
at org.springframework.web.filter.CompositeFilter.doFilter(CompositeFilter.java:74)
at org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration$CompositeFilterChainProxy.doFilter(WebSecurityConfiguration.java:317)
at org.springframework.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:355)
at org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:272)
at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:101)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:101)
at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:406)
at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:71)
at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:1307)
at java.base/java.lang.Thread.run(Thread.java:1474)
DEBUG o.s.s.w.s.HttpSessionRequestCache : Saved request http://localhost:8080/error?continue to session
DEBUG o.s.s.w.a.DelegatingAuthenticationEntryPoint : Trying to match using Or [RequestHeaderRequestMatcher [expectedHeaderName=X-Requested-With, expectedHeaderValue=XMLHttpRequest], And [Not [MediaTypeRequestMatcher [contentNegotiationStrategy=org.springframework.web.accept.ContentNegotiationManager@<hash>, matchingMediaTypes=[text/html], useEquals=false, ignoredMediaTypes=[]]], MediaTypeRequestMatcher [contentNegotiationStrategy=org.springframework.web.accept.ContentNegotiationManager@<hash>, matchingMediaTypes=[application/atom+xml, application/x-www-form-urlencoded, application/json, application/octet-stream, application/xml, multipart/form-data, text/xml], useEquals=false, ignoredMediaTypes=[*/*]]], MediaTypeRequestMatcher [contentNegotiationStrategy=org.springframework.web.accept.ContentNegotiationManager@<hash>, matchingMediaTypes=[*/*], useEquals=true, ignoredMediaTypes=[]]]
DEBUG o.s.s.w.a.DelegatingAuthenticationEntryPoint : Match found! Executing org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint@<hash>
DEBUG o.s.s.w.a.DelegatingAuthenticationEntryPoint : Trying to match using RequestHeaderRequestMatcher [expectedHeaderName=X-Requested-With, expectedHeaderValue=XMLHttpRequest]
DEBUG o.s.s.w.a.DelegatingAuthenticationEntryPoint : No match found. Using default entry point org.springframework.security.web.authentication.www.BasicAuthenticationEntryPoint@<hash>
DEBUG o.a.c.c.C.[.[.[/].[dispatcherServlet] : Disabling the response for further output

View File

@@ -0,0 +1,30 @@
==============================================================================
docs/output/demo5-custom-placement.txt
Four custom filters at four anchors.
GET /diag/chains (profile: custom)
==============================================================================
FilterChainProxy holds 1 SecurityFilterChain(s).
The FIRST chain whose matcher accepts the request wins; the rest are never consulted.
=== chain 1/1 matcher = any request (20 filters)
1/20 DisableEncodeUrlFilter order=100
2/20 WebAsyncManagerIntegrationFilter order=600
3/20 SecurityContextHolderFilter order=700
4/20 RequestIdFilter order=? (not in the registration table)
5/20 HeaderWriterFilter order=900
6/20 CsrfFilter order=1100
7/20 LogoutFilter order=1200
8/20 ApiKeyAuthenticationFilter order=? (not in the registration table)
9/20 UsernamePasswordAuthenticationFilter order=2100
10/20 DefaultResourcesFilter order=2400
11/20 DefaultLoginPageGeneratingFilter order=2500
12/20 DefaultLogoutPageGeneratingFilter order=2600
13/20 BasicAuthenticationFilter order=3100
14/20 RequestCacheAwareFilter order=3300
15/20 SecurityContextHolderAwareRequestFilter order=3400
16/20 AnonymousAuthenticationFilter order=3700
17/20 TenantFilter order=? (not in the registration table)
18/20 ExceptionTranslationFilter order=4000
19/20 TenantFilter order=? (not in the registration table)
20/20 AuthorizationFilter order=4200

View File

@@ -0,0 +1,34 @@
==============================================================================
docs/output/demo6-exception-translation.txt
Two identical TenantFilters, 300 apart, straddling ExceptionTranslationFilter (4000).
Both throw AccessDeniedException. Neither of them produces a 403 - for two different reasons.
==============================================================================
$ curl -i -u alice:password localhost:8080/tenant/doc # filter at order 3701
HTTP/1.1 500
$ curl -i -u alice:password localhost:8080/tenant/translated # filter at order 4001
HTTP/1.1 200
$ curl -i -u alice:password -H "X-Tenant-Id: acme" localhost:8080/tenant/doc
HTTP/1.1 200
$ curl -s -H "X-Api-Key: let-me-in" localhost:8080/whoami # api key filter at 1201
principal=api-client authenticated=true authorities=[ROLE_API] type=UsernamePasswordAuthenticationToken counting-filter-invocations=null
$ curl -s -D- -o /dev/null localhost:8080/public/hello | grep X-Request-Id # filter at 701
X-Request-Id: 74ee45fe-9eda-3820-8110-63a6116aa155
--- the same four filters, run with -DUNIQUE_ONCE_KEY=true ---
Each TenantFilter now has its own OncePerRequestFilter key, so both actually execute.
$ curl -i -u alice:password localhost:8080/tenant/doc # filter at order 3701
HTTP/1.1 500
$ curl -i -u alice:password localhost:8080/tenant/translated # filter at order 4001
HTTP/1.1 403
Same code, same exception, 300 order slots apart:
order 3701 - below ExceptionTranslationFilter (4000) - the throw escapes untranslated
order 4001 - above it - the throw becomes a 403

View File

@@ -0,0 +1,27 @@
==============================================================================
docs/output/demo7-misordered.txt
The SAME ApiKeyAuthenticationFilter, moved from order 1201 to 4201.
The key is valid. The filter runs. The request is still rejected.
==============================================================================
--- chain ---
FilterChainProxy holds 1 SecurityFilterChain(s).
The FIRST chain whose matcher accepts the request wins; the rest are never consulted.
=== chain 1/1 matcher = any request (12 filters)
1/12 DisableEncodeUrlFilter order=100
2/12 WebAsyncManagerIntegrationFilter order=600
3/12 SecurityContextHolderFilter order=700
4/12 HeaderWriterFilter order=900
5/12 LogoutFilter order=1200
6/12 BasicAuthenticationFilter order=3100
7/12 RequestCacheAwareFilter order=3300
8/12 SecurityContextHolderAwareRequestFilter order=3400
9/12 AnonymousAuthenticationFilter order=3700
10/12 ExceptionTranslationFilter order=4000
11/12 AuthorizationFilter order=4200
12/12 ApiKeyAuthenticationFilter order=? (not in the registration table)
$ curl -i -H "X-Api-Key: let-me-in" localhost:8080/whoami
HTTP/1.1 401

View File

@@ -0,0 +1,23 @@
==============================================================================
docs/output/demo8-tie.txt
addFilterBefore(x, CsrfFilter.class) twice. Both filters get order 1099.
==============================================================================
--- A registered first ---
executed order: [A, B]
--- chain positions ---
5/12 MarkerFilterA order=? (not in the registration table)
6/12 MarkerFilterB order=? (not in the registration table)
--- B registered first (-DTIE_REVERSED=true), nothing else changed ---
executed order: [B, A]
--- chain positions ---
5/12 MarkerFilterB order=? (not in the registration table)
6/12 MarkerFilterA order=? (not in the registration table)
The order number is identical in both runs. The executed order follows the order of
the addFilterBefore calls, because List.sort is stable - not because Spring Security
promises anything about ties.

View File

@@ -0,0 +1,20 @@
==============================================================================
docs/output/demo9-double-registration.txt
A CountingFilter @Bean added to the security chain. Boot ALSO registers every Filter bean
with the servlet container, so it is in two chains at once.
==============================================================================
--- profile: doublereg ---
$ curl -sD- -o /dev/null localhost:8080/whoami | grep X-Counting
X-Counting-Filter-Invocations: 2
$ curl -s localhost:8080/diag/servlet-filters | grep -i counting
countingFilter com.ankurm.chain.filter.CountingFilter urls=[/*]
--- profile: doublereg,fixed (FilterRegistrationBean.setEnabled(false)) ---
$ curl -sD- -o /dev/null localhost:8080/whoami | grep X-Counting
X-Counting-Filter-Invocations: 1
$ curl -s localhost:8080/diag/servlet-filters | grep -i counting
(not registered with the container)

View File

@@ -0,0 +1,114 @@
[INFO] T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.ankurm.chain.FilterChainContractTest
[INFO] Running two filters added before the same anchor
01:50:14.438 [main] INFO org.springframework.test.context.support.AnnotationConfigContextLoaderUtils -- Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest$Tie]: Tie does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
01:50:14.614 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper -- Found @SpringBootConfiguration com.ankurm.chain.FilterChainDemoApplication for test class com.ankurm.chain.FilterChainContractTest$Tie
01:50:14.679 [main] INFO org.springframework.test.context.support.AnnotationConfigContextLoaderUtils -- Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest$Tie]: Tie does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
01:50:14.680 [main] INFO org.springframework.test.context.support.AnnotationConfigContextLoaderUtils -- Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest]: FilterChainContractTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
01:50:14.684 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper -- Found @SpringBootConfiguration com.ankurm.chain.FilterChainDemoApplication for test class com.ankurm.chain.FilterChainContractTest$Tie
INFO c.a.chain.FilterChainContractTest$Tie : Starting FilterChainContractTest.Tie using Java 25.0.4.1 with PID 8588 (started by root in /tmp/ssd/filter-chain)
INFO c.a.chain.FilterChainContractTest$Tie : The following 1 profile is active: "tie"
INFO o.s.s.c.a.a.c.InitializeUserDetailsBeanManagerConfigurer$InitializeUserDetailsManagerConfigurer : Global AuthenticationManager configured with UserDetailsService bean with name users
INFO o.s.b.t.m.w.SpringBootMockServletContext : Initializing Spring TestDispatcherServlet ''
INFO o.s.t.web.servlet.TestDispatcherServlet : Initializing Servlet ''
INFO o.s.b.a.e.web.EndpointLinksResolver : Exposing 1 endpoint beneath base path '/actuator'
INFO o.s.t.web.servlet.TestDispatcherServlet : Completed initialization in 33 ms
INFO c.a.chain.FilterChainContractTest$Tie : Started FilterChainContractTest.Tie in 2.615 seconds (process running for 3.902)
Mockito is currently self-attaching to enable the inline-mock-maker. This will no longer work in future releases of the JDK. Please add Mockito as an agent to your build as described in Mockito's documentation: https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3
OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
WARNING: A Java agent has been loaded dynamically (/root/.m2/repository/net/bytebuddy/byte-buddy-agent/1.18.11/byte-buddy-agent-1.18.11.jar)
WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading to hide this warning
WARNING: If a serviceability tool is not in use, please run with -Djdk.instrument.traceUsage for more information
WARNING: Dynamic loading of agents will be disallowed by default in a future release
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.951 s -- in two filters added before the same anchor
[INFO] Running the reference configuration
INFO o.s.t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest$Baseline]: Baseline does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
INFO o.s.b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.chain.FilterChainDemoApplication for test class com.ankurm.chain.FilterChainContractTest$Baseline
INFO o.s.t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest$Baseline]: Baseline does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
INFO o.s.t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest]: FilterChainContractTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
INFO o.s.b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.chain.FilterChainDemoApplication for test class com.ankurm.chain.FilterChainContractTest$Baseline
INFO c.a.c.FilterChainContractTest$Baseline : Starting FilterChainContractTest.Baseline using Java 25.0.4.1 with PID 8588 (started by root in /tmp/ssd/filter-chain)
INFO c.a.c.FilterChainContractTest$Baseline : The following 1 profile is active: "baseline"
INFO o.s.s.c.a.a.c.InitializeUserDetailsBeanManagerConfigurer$InitializeUserDetailsManagerConfigurer : Global AuthenticationManager configured with UserDetailsService bean with name users
INFO o.s.b.t.m.w.SpringBootMockServletContext : Initializing Spring TestDispatcherServlet ''
INFO o.s.t.web.servlet.TestDispatcherServlet : Initializing Servlet ''
INFO o.s.b.a.e.web.EndpointLinksResolver : Exposing 1 endpoint beneath base path '/actuator'
INFO o.s.t.web.servlet.TestDispatcherServlet : Completed initialization in 6 ms
INFO c.a.c.FilterChainContractTest$Baseline : Started FilterChainContractTest.Baseline in 0.698 seconds (process running for 5.482)
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.761 s -- in the reference configuration
[INFO] Running WebSecurity.ignoring()
INFO o.s.t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest$Ignoring]: Ignoring does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
INFO o.s.b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.chain.FilterChainDemoApplication for test class com.ankurm.chain.FilterChainContractTest$Ignoring
INFO o.s.t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest$Ignoring]: Ignoring does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
INFO o.s.t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest]: FilterChainContractTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
INFO o.s.b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.chain.FilterChainDemoApplication for test class com.ankurm.chain.FilterChainContractTest$Ignoring
INFO c.a.c.FilterChainContractTest$Ignoring : Starting FilterChainContractTest.Ignoring using Java 25.0.4.1 with PID 8588 (started by root in /tmp/ssd/filter-chain)
INFO c.a.c.FilterChainContractTest$Ignoring : The following 1 profile is active: "ignoring"
INFO o.s.s.c.a.a.c.InitializeUserDetailsBeanManagerConfigurer$InitializeUserDetailsManagerConfigurer : Global AuthenticationManager configured with UserDetailsService bean with name users
WARN o.s.s.c.a.web.builders.WebSecurity : You are asking Spring Security to ignore PathPattern [/static/**]. This is not recommended -- please use permitAll via HttpSecurity#authorizeHttpRequests instead.
INFO o.s.b.t.m.w.SpringBootMockServletContext : Initializing Spring TestDispatcherServlet ''
INFO o.s.t.web.servlet.TestDispatcherServlet : Initializing Servlet ''
INFO o.s.b.a.e.web.EndpointLinksResolver : Exposing 1 endpoint beneath base path '/actuator'
INFO o.s.t.web.servlet.TestDispatcherServlet : Completed initialization in 8 ms
INFO c.a.c.FilterChainContractTest$Ignoring : Started FilterChainContractTest.Ignoring in 0.616 seconds (process running for 6.187)
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.771 s -- in WebSecurity.ignoring()
[INFO] Running custom filters
INFO o.s.t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest$Custom]: Custom does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
INFO o.s.b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.chain.FilterChainDemoApplication for test class com.ankurm.chain.FilterChainContractTest$Custom
INFO o.s.t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest$Custom]: Custom does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
INFO o.s.t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest]: FilterChainContractTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
INFO o.s.b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.chain.FilterChainDemoApplication for test class com.ankurm.chain.FilterChainContractTest$Custom
INFO c.a.c.FilterChainContractTest$Custom : Starting FilterChainContractTest.Custom using Java 25.0.4.1 with PID 8588 (started by root in /tmp/ssd/filter-chain)
INFO c.a.c.FilterChainContractTest$Custom : The following 1 profile is active: "custom"
INFO o.s.s.c.a.a.c.InitializeUserDetailsBeanManagerConfigurer$InitializeUserDetailsManagerConfigurer : Global AuthenticationManager configured with UserDetailsService bean with name users
INFO o.s.b.t.m.w.SpringBootMockServletContext : Initializing Spring TestDispatcherServlet ''
INFO o.s.t.web.servlet.TestDispatcherServlet : Initializing Servlet ''
INFO o.s.b.a.e.web.EndpointLinksResolver : Exposing 1 endpoint beneath base path '/actuator'
INFO o.s.t.web.servlet.TestDispatcherServlet : Completed initialization in 4 ms
INFO c.a.c.FilterChainContractTest$Custom : Started FilterChainContractTest.Custom in 0.477 seconds (process running for 6.82)
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.578 s -- in custom filters
[INFO] Running an authentication filter after AuthorizationFilter
INFO o.s.t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest$Misordered]: Misordered does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
INFO o.s.b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.chain.FilterChainDemoApplication for test class com.ankurm.chain.FilterChainContractTest$Misordered
INFO o.s.t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest$Misordered]: Misordered does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
INFO o.s.t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest]: FilterChainContractTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
INFO o.s.b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.chain.FilterChainDemoApplication for test class com.ankurm.chain.FilterChainContractTest$Misordered
INFO c.a.c.FilterChainContractTest$Misordered : Starting FilterChainContractTest.Misordered using Java 25.0.4.1 with PID 8588 (started by root in /tmp/ssd/filter-chain)
INFO c.a.c.FilterChainContractTest$Misordered : The following 1 profile is active: "misordered"
INFO o.s.s.c.a.a.c.InitializeUserDetailsBeanManagerConfigurer$InitializeUserDetailsManagerConfigurer : Global AuthenticationManager configured with UserDetailsService bean with name users
INFO o.s.b.t.m.w.SpringBootMockServletContext : Initializing Spring TestDispatcherServlet ''
INFO o.s.t.web.servlet.TestDispatcherServlet : Initializing Servlet ''
INFO o.s.b.a.e.web.EndpointLinksResolver : Exposing 1 endpoint beneath base path '/actuator'
INFO o.s.t.web.servlet.TestDispatcherServlet : Completed initialization in 4 ms
INFO c.a.c.FilterChainContractTest$Misordered : Started FilterChainContractTest.Misordered in 0.352 seconds (process running for 7.247)
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.389 s -- in an authentication filter after AuthorizationFilter
[INFO] Running three SecurityFilterChain beans
INFO o.s.t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest$MultiChain]: MultiChain does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
INFO o.s.b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.chain.FilterChainDemoApplication for test class com.ankurm.chain.FilterChainContractTest$MultiChain
INFO o.s.t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest$MultiChain]: MultiChain does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
INFO o.s.t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.chain.FilterChainContractTest]: FilterChainContractTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
INFO o.s.b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.chain.FilterChainDemoApplication for test class com.ankurm.chain.FilterChainContractTest$MultiChain
INFO c.a.c.FilterChainContractTest$MultiChain : Starting FilterChainContractTest.MultiChain using Java 25.0.4.1 with PID 8588 (started by root in /tmp/ssd/filter-chain)
INFO c.a.c.FilterChainContractTest$MultiChain : The following 1 profile is active: "multichain"
INFO o.s.s.c.a.a.c.InitializeUserDetailsBeanManagerConfigurer$InitializeUserDetailsManagerConfigurer : Global AuthenticationManager configured with UserDetailsService bean with name users
INFO o.s.b.t.m.w.SpringBootMockServletContext : Initializing Spring TestDispatcherServlet ''
INFO o.s.t.web.servlet.TestDispatcherServlet : Initializing Servlet ''
INFO o.s.b.a.e.web.EndpointLinksResolver : Exposing 1 endpoint beneath base path '/actuator'
INFO o.s.t.web.servlet.TestDispatcherServlet : Completed initialization in 18 ms
INFO c.a.c.FilterChainContractTest$MultiChain : Started FilterChainContractTest.MultiChain in 0.398 seconds (process running for 7.681)
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.426 s -- in three SecurityFilterChain beans
[INFO] Tests run: 0, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 6.924 s -- in com.ankurm.chain.FilterChainContractTest
[INFO] Running com.ankurm.chain.FilterOrderTableTest
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.052 s -- in com.ankurm.chain.FilterOrderTableTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 21, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 9.196 s
[INFO] Finished at: <timestamp>
[INFO] ------------------------------------------------------------------------

67
filter-chain/pom.xml Normal file
View File

@@ -0,0 +1,67 @@
<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 that this
module gets Boot's own compiler settings, including <parameters>true</parameters>.
Every version below is managed by the parent; nothing here is pinned by hand. See
docs/01-the-two-proxies.md for why this module is a real servlet app while the other
modules in this repository are plain main() classes. -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>filter-chain-verify</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>
<!-- Present ONLY so docs/06-observability-changes-the-trace.md can show what an
ObservationRegistry bean does to FilterChainProxy's decorator. Remove it and the
TRACE output in docs/output/demo3-trace-annotated.txt changes shape. -->
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</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-webmvc-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>

230
filter-chain/scripts/run-all.sh Executable file
View File

@@ -0,0 +1,230 @@
#!/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 is restarted 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 the committed files diff cleanly: timestamps, ports, session ids,
# object hashes, and the JAVA_TOOL_OPTIONS banner this sandbox injects.
scrub() {
sed -E \
-e 's/[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.]+Z?/<timestamp>/g' \
-e 's/@[0-9a-f]{6,}/@<hash>/g' \
-e 's/(JSESSIONID=)[0-9A-F]+/\1<session>/g' \
-e 's/remote=\/127\.0\.0\.1:[0-9]+/remote=\/127.0.0.1:<port>/g' \
-e '/Picked up JAVA_TOOL_OPTIONS/d'
}
trace_request() { # trace_request <curl args...>
local mark
mark=$(wc -l < /tmp/trace.log)
curl -s -o /dev/null "$@" > /dev/null 2>&1 || true
sleep 1
sed -n "$((mark + 1)),\$p" /tmp/trace.log \
| grep -vE 'tomcat|catalina|DefaultListableBeanFactory|LimitLatch|NioEndpoint|SocketWrapperBase|Parameters' \
| scrub
}
########################################################################################
# 1 + 2 + 12: the reference chain, the order table, and the container's own filters
########################################################################################
./scripts/run.sh baseline > /dev/null
{
hdr "docs/output/demo1-order-table.txt
FilterOrderRegistration, read out of spring-security-config 7.1.1 by reflection.
GET /diag/order"
curl -s localhost:8080/diag/order
} | scrub > "$OUT/demo1-order-table.txt"
{
hdr "docs/output/demo2-default-chain.txt
The chain FilterChainProxy actually holds for the reference configuration.
GET /diag/chains (profile: baseline)"
curl -s localhost:8080/diag/chains
printf '\n--- and the same list as Spring Security prints it at startup (DEBUG) ---\n\n'
grep -m1 "Will secure" "${LOG:-/tmp/filter-chain-app.log}" || true
} | scrub > "$OUT/demo2-default-chain.txt"
{
hdr "docs/output/demo12-servlet-filters.txt
What the servlet container has registered. FilterChainProxy is ONE entry here.
GET /diag/servlet-filters (profile: baseline)"
curl -s localhost:8080/diag/servlet-filters
} | scrub > "$OUT/demo12-servlet-filters.txt"
########################################################################################
# 3 + 4: TRACE for a request that succeeds and a request that is rejected
########################################################################################
TRACE=1 LOG=/tmp/trace.log ./scripts/run.sh baseline > /dev/null
{
hdr "docs/output/demo3-trace-authenticated.txt
One authenticated GET, org.springframework.security at TRACE.
curl -u alice:password localhost:8080/whoami
Container and bean-factory lines removed; nothing else edited."
trace_request -u alice:password localhost:8080/whoami
} > "$OUT/demo3-trace-authenticated.txt"
{
hdr "docs/output/demo4-trace-csrf-403.txt
One POST with no CSRF token. Note WHICH filter rejects it and how far the request got.
curl -X POST -u alice:password localhost:8080/hello"
trace_request -X POST -u alice:password localhost:8080/hello
} > "$OUT/demo4-trace-csrf-403.txt"
########################################################################################
# 5 + 6: custom filters - where they land, and the ExceptionTranslationFilter boundary
########################################################################################
./scripts/run.sh custom > /dev/null
{
hdr "docs/output/demo5-custom-placement.txt
Four custom filters at four anchors.
GET /diag/chains (profile: custom)"
curl -s localhost:8080/diag/chains
} | scrub > "$OUT/demo5-custom-placement.txt"
{
hdr "docs/output/demo6-exception-translation.txt
Two identical TenantFilters, 300 apart, straddling ExceptionTranslationFilter (4000).
Both throw AccessDeniedException. Neither of them produces a 403 - for two different reasons."
printf '\n$ curl -i -u alice:password localhost:8080/tenant/doc # filter at order 3701\n'
curl -s -i -u alice:password localhost:8080/tenant/doc | head -1
printf '\n$ curl -i -u alice:password localhost:8080/tenant/translated # filter at order 4001\n'
curl -s -i -u alice:password localhost:8080/tenant/translated | head -1
printf '\n$ curl -i -u alice:password -H "X-Tenant-Id: acme" localhost:8080/tenant/doc\n'
curl -s -i -u alice:password -H "X-Tenant-Id: acme" localhost:8080/tenant/doc | head -1
printf '\n$ curl -s -H "X-Api-Key: let-me-in" localhost:8080/whoami # api key filter at 1201\n'
curl -s -H "X-Api-Key: let-me-in" localhost:8080/whoami
printf '\n$ curl -s -D- -o /dev/null localhost:8080/public/hello | grep X-Request-Id # filter at 701\n'
curl -s -D- -o /dev/null localhost:8080/public/hello | grep -i 'x-request-id' || true
} | scrub > "$OUT/demo6-exception-translation.txt"
# The filter at 4001 above answered 200, not 403. It is in the chain - demo5 shows it - but it
# never ran: both TenantFilter instances are the same OncePerRequestFilter subclass, so they
# share the "<class>.FILTERED" request attribute and the second one skips itself. Give each
# instance its own key and only then does the placement question become visible.
JVM_ARGS="-DUNIQUE_ONCE_KEY=true" ./scripts/run.sh custom > /dev/null
{
printf '\n--- the same four filters, run with -DUNIQUE_ONCE_KEY=true ---\n'
printf 'Each TenantFilter now has its own OncePerRequestFilter key, so both actually execute.\n'
printf '\n$ curl -i -u alice:password localhost:8080/tenant/doc # filter at order 3701\n'
curl -s -i -u alice:password localhost:8080/tenant/doc | head -1
printf '\n$ curl -i -u alice:password localhost:8080/tenant/translated # filter at order 4001\n'
curl -s -i -u alice:password localhost:8080/tenant/translated | head -1
printf '\nSame code, same exception, 300 order slots apart:\n'
printf ' order 3701 - below ExceptionTranslationFilter (4000) - the throw escapes untranslated\n'
printf ' order 4001 - above it - the throw becomes a 403\n'
} | scrub >> "$OUT/demo6-exception-translation.txt"
########################################################################################
# 7: the authentication filter on the wrong side of AuthorizationFilter
########################################################################################
./scripts/run.sh misordered > /dev/null
{
hdr "docs/output/demo7-misordered.txt
The SAME ApiKeyAuthenticationFilter, moved from order 1201 to 4201.
The key is valid. The filter runs. The request is still rejected."
printf '\n--- chain ---\n'
curl -s localhost:8080/diag/chains
printf '\n$ curl -i -H "X-Api-Key: let-me-in" localhost:8080/whoami\n'
curl -s -i -H "X-Api-Key: let-me-in" localhost:8080/whoami | head -1
} | scrub > "$OUT/demo7-misordered.txt"
########################################################################################
# 8: two filters, one anchor, one order number
########################################################################################
./scripts/run.sh tie > /dev/null
{
hdr "docs/output/demo8-tie.txt
addFilterBefore(x, CsrfFilter.class) twice. Both filters get order 1099."
printf '\n--- A registered first ---\n'
curl -s localhost:8080/markers
printf '\n--- chain positions ---\n'
curl -s localhost:8080/diag/chains | grep -E 'MarkerFilter|CsrfFilter'
} | scrub > "$OUT/demo8-tie.txt"
JVM_ARGS="-DTIE_REVERSED=true" ./scripts/run.sh tie > /dev/null
{
printf '\n--- B registered first (-DTIE_REVERSED=true), nothing else changed ---\n'
curl -s localhost:8080/markers
printf '\n--- chain positions ---\n'
curl -s localhost:8080/diag/chains | grep -E 'MarkerFilter|CsrfFilter'
printf '\nThe order number is identical in both runs. The executed order follows the order of\n'
printf 'the addFilterBefore calls, because List.sort is stable - not because Spring Security\n'
printf 'promises anything about ties.\n'
} | scrub >> "$OUT/demo8-tie.txt"
########################################################################################
# 9: one filter bean, two registrations
########################################################################################
./scripts/run.sh doublereg > /dev/null
{
hdr "docs/output/demo9-double-registration.txt
A CountingFilter @Bean added to the security chain. Boot ALSO registers every Filter bean
with the servlet container, so it is in two chains at once."
printf '\n--- profile: doublereg ---\n'
printf '$ curl -sD- -o /dev/null localhost:8080/whoami | grep X-Counting\n'
curl -s -D- -o /dev/null localhost:8080/whoami | grep -i 'x-counting' || true
printf '\n$ curl -s localhost:8080/diag/servlet-filters | grep -i counting\n'
curl -s localhost:8080/diag/servlet-filters | grep -i counting || true
} | scrub > "$OUT/demo9-double-registration.txt"
./scripts/run.sh doublereg,fixed > /dev/null
{
printf '\n--- profile: doublereg,fixed (FilterRegistrationBean.setEnabled(false)) ---\n'
printf '$ curl -sD- -o /dev/null localhost:8080/whoami | grep X-Counting\n'
curl -s -D- -o /dev/null localhost:8080/whoami | grep -i 'x-counting' || true
printf '\n$ curl -s localhost:8080/diag/servlet-filters | grep -i counting\n'
curl -s localhost:8080/diag/servlet-filters | grep -i counting || printf ' (not registered with the container)\n'
} | scrub >> "$OUT/demo9-double-registration.txt"
########################################################################################
# 10: three chains
########################################################################################
./scripts/run.sh multichain > /dev/null
{
hdr "docs/output/demo10-multichain.txt
Three SecurityFilterChain beans. Evaluation stops at the first match.
GET /diag/chains (profile: multichain)"
curl -s localhost:8080/diag/chains
printf '\n--- which chain served what ---\n'
printf '$ curl -i -u alice:password localhost:8080/api/data\n'
curl -s -i -u alice:password localhost:8080/api/data | head -1
printf '$ curl -i localhost:8080/whoami # browser chain: redirect to the login page\n'
curl -s -i localhost:8080/whoami | head -1
} | scrub > "$OUT/demo10-multichain.txt"
########################################################################################
# 11: permitAll vs ignoring
########################################################################################
./scripts/run.sh ignoring > /dev/null
{
hdr "docs/output/demo11-ignoring-vs-permitall.txt
WebSecurity.ignoring() produces a real chain with ZERO filters.
GET /diag/chains (profile: ignoring)"
curl -s localhost:8080/diag/chains
printf '\n--- response headers, ignored path vs permitAll path ---\n'
printf '$ curl -sD- -o /dev/null localhost:8080/static/asset.txt\n'
curl -s -D- -o /dev/null localhost:8080/static/asset.txt | grep -iE '^(HTTP|X-Content|X-XSS|Cache-Control|Pragma|Expires)' || true
printf '\n$ curl -sD- -o /dev/null localhost:8080/public/hello\n'
curl -s -D- -o /dev/null localhost:8080/public/hello | grep -iE '^(HTTP|X-Content|X-XSS|Cache-Control|Pragma|Expires)' || true
} | scrub > "$OUT/demo11-ignoring-vs-permitall.txt"
./scripts/stop.sh
########################################################################################
# The assertions
########################################################################################
mvn -B test 2>&1 | sed -n '/T E S T S/,$p' | scrub > "$OUT/tests.txt" || true
echo
echo "regenerated:"
ls -1 "$OUT"

38
filter-chain/scripts/run.sh Executable file
View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Start the demo application with a given set of profiles and wait until it answers.
#
# ./scripts/run.sh baseline
# ./scripts/run.sh custom
# ./scripts/run.sh doublereg,fixed
# TRACE=1 ./scripts/run.sh baseline # org.springframework.security at TRACE
#
# Kills any previous instance first - by main class, never by a 'spring-boot' pattern, which
# would also match the shell running this script.
set -eu
cd "$(dirname "$0")/.."
PROFILES="${1:-baseline}"
LOG="${LOG:-/tmp/filter-chain-app.log}"
./scripts/stop.sh
EXTRA=""
if [ "${TRACE:-0}" = "1" ]; then
EXTRA="-Dlogging.level.org.springframework.security=TRACE"
fi
setsid nohup mvn -B org.springframework.boot:spring-boot-maven-plugin:run \
-Dspring-boot.run.profiles="$PROFILES" \
-Dspring-boot.run.jvmArguments="${JVM_ARGS:-} $EXTRA" \
> "$LOG" 2>&1 < /dev/null &
for _ in $(seq 1 90); do
if curl -sf -o /dev/null http://localhost:8080/diag/chains 2>/dev/null; then
echo "started with profiles: $PROFILES (log: $LOG)"
exit 0
fi
sleep 2
done
echo "application did not become ready; see $LOG" >&2
tail -40 "$LOG" >&2
exit 1

11
filter-chain/scripts/stop.sh Executable file
View 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. And note that we match the MAIN CLASS, not 'spring-boot' - matching 'spring-boot'
# also matches the shell command line that started it, which kills your own shell.
set -eu
for pid in $(ps -eo pid,cmd | grep '[F]ilterChainDemoApplication' | awk '{print $1}'); do
kill -9 "$pid" 2>/dev/null || true
done
sleep 1

View File

@@ -0,0 +1,28 @@
package com.ankurm.chain;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Companion application for
* <a href="https://ankurm.com/spring-security-filter-chain-explained/">The Spring Security
* Filter Chain Explained</a>.
*
* <p>Every scenario in the article is a Spring profile on this one application. Nothing here
* is a toy re-implementation: the chains you see printed by {@code /diag/chains} are the
* chains {@code FilterChainProxy} actually holds, read back out of the live bean.
*
* <p>Start it with {@code ./scripts/run.sh <profile>} and regenerate every captured file
* under {@code docs/output/} with {@code ./scripts/run-all.sh}.
*
* @see com.ankurm.chain.web.DiagnosticsController
* @see <a href="../../../../../../docs/README.md">Documentation index</a>
*/
@SpringBootApplication
public class FilterChainDemoApplication {
public static void main(String[] args) {
SpringApplication.run(FilterChainDemoApplication.class, args);
}
}

View File

@@ -0,0 +1,43 @@
package com.ankurm.chain.config;
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;
/**
* The reference configuration &mdash; the one whose chain is reproduced in the article's table.
* Active by default ({@code spring.profiles.default=baseline}).
*
* <p>It turns on exactly the four features the reference documentation's example turns on, so
* the chain it produces is directly comparable: CSRF, HTTP Basic, form login, and request
* authorization. Everything <em>else</em> in the resulting chain is there because
* {@code HttpSecurity} puts it there whether you ask or not, which is the interesting part.
*
* <p>Declaration order in this method is irrelevant. {@code HttpSecurity.performBuild()} sorts
* the accumulated filters with {@code OrderComparator} before handing them to
* {@code DefaultSecurityFilterChain}, so moving {@code .csrf(..)} below {@code .httpBasic(..)}
* changes nothing. Pinned by
* {@code FilterChainContractTest.declarationOrderDoesNotAffectChainOrder}.
*
* @see <a href="../../../../../../../docs/02-the-default-chain.md">docs/02-the-default-chain.md</a>
*/
@Configuration
@Profile("baseline")
public class BaselineSecurityConfig {
@Bean
SecurityFilterChain baseline(HttpSecurity http) throws Exception {
http
.csrf(Customizer.withDefaults())
.httpBasic(Customizer.withDefaults())
.formLogin(Customizer.withDefaults())
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/public/**", "/diag/**").permitAll()
.anyRequest().authenticated());
return http.build();
}
}

View File

@@ -0,0 +1,61 @@
package com.ankurm.chain.config;
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.access.ExceptionTranslationFilter;
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;
import org.springframework.security.web.authentication.logout.LogoutFilter;
import org.springframework.security.web.context.SecurityContextHolderFilter;
import com.ankurm.chain.filter.ApiKeyAuthenticationFilter;
import com.ankurm.chain.filter.RequestIdFilter;
import com.ankurm.chain.filter.TenantFilter;
/**
* Three custom filters at the three placements the reference documentation recommends, plus a
* fourth that shows why one of those recommendations is wrong for filters that deny by throwing.
*
* <pre>
* addFilterAfter(RequestIdFilter, SecurityContextHolderFilter) -&gt; 701
* addFilterAfter(ApiKeyAuthFilter, LogoutFilter) -&gt; 1201
* addFilterAfter(TenantFilter[doc-placement], AnonymousAuthenticationFilter) -&gt; 3701
* addFilterAfter(TenantFilter[after-translation], ExceptionTranslationFilter) -&gt; 4001
* </pre>
*
* <p>The two {@code TenantFilter}s are identical code at two orders, 300 apart, straddling
* {@code ExceptionTranslationFilter} at 4000. One produces a 403, the other a 500. Which is
* which is the point &mdash; see {@code docs/05-failure-modes.md}.
*
* @see <a href="../../../../../../../docs/04-where-custom-filters-land.md">docs/04-where-custom-filters-land.md</a>
*/
@Configuration
@Profile("custom")
public class CustomFiltersSecurityConfig {
@Bean
SecurityFilterChain custom(HttpSecurity http) throws Exception {
http
.csrf(Customizer.withDefaults())
.httpBasic(Customizer.withDefaults())
.formLogin(Customizer.withDefaults())
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/public/**", "/diag/**").permitAll()
.anyRequest().authenticated())
// 700 + 1: an exploit-protection-ish filter, after the context is loaded.
.addFilterAfter(new RequestIdFilter(), SecurityContextHolderFilter.class)
// 1200 + 1: the documented placement for an authentication filter.
.addFilterAfter(new ApiKeyAuthenticationFilter(), LogoutFilter.class)
// 3700 + 1: the documented placement for an authorization filter - and BELOW
// ExceptionTranslationFilter (4000), so an AccessDeniedException thrown here is
// never translated.
.addFilterAfter(new TenantFilter("doc-placement", "/tenant/doc"), AnonymousAuthenticationFilter.class)
// 4000 + 1: above ExceptionTranslationFilter, so the same throw becomes a 403.
.addFilterAfter(new TenantFilter("after-translation", "/tenant/translated"), ExceptionTranslationFilter.class);
return http.build();
}
}

View File

@@ -0,0 +1,59 @@
package com.ankurm.chain.config;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
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;
import org.springframework.security.web.csrf.CsrfFilter;
import com.ankurm.chain.filter.CountingFilter;
/**
* The double-registration trap, and its fix, as two profiles over one piece of code.
*
* <p>Spring Boot registers <em>every</em> {@code jakarta.servlet.Filter} bean with the servlet
* container. Add that same bean to the security chain and it is now in two chains: the
* container's, wrapping {@code FilterChainProxy}, and Spring Security's, inside it. It runs
* twice for every request, including requests that no {@code SecurityFilterChain} matches.
*
* <p>Under {@code doublereg} the counting filter is a plain {@code @Bean} and the response
* header reports <strong>2</strong>. Under {@code doublereg,fixed} the same bean is wrapped in a
* disabled {@code FilterRegistrationBean}, which suppresses the container registration only, and
* the header reports <strong>1</strong>.
*
* @see <a href="../../../../../../../docs/05-failure-modes.md">docs/05-failure-modes.md</a>
*/
@Configuration
@Profile("doublereg")
public class DoubleRegistrationConfig {
@Bean
CountingFilter countingFilter() {
return new CountingFilter();
}
/**
* Present only under {@code doublereg,fixed}. {@code setEnabled(false)} tells Boot not to
* register the filter with the container; the bean itself still exists and is still added
* to the security chain below.
*/
@Bean
@Profile("fixed")
FilterRegistrationBean<CountingFilter> disableContainerRegistration(CountingFilter filter) {
FilterRegistrationBean<CountingFilter> registration = new FilterRegistrationBean<>(filter);
registration.setEnabled(false);
return registration;
}
@Bean
SecurityFilterChain doubleRegistration(HttpSecurity http, CountingFilter filter) throws Exception {
http
.csrf((csrf) -> csrf.disable())
.authorizeHttpRequests((authorize) -> authorize.anyRequest().permitAll())
.addFilterBefore(filter, CsrfFilter.class);
return http.build();
}
}

View File

@@ -0,0 +1,45 @@
package com.ankurm.chain.config;
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.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.web.SecurityFilterChain;
/**
* {@code permitAll()} and {@code WebSecurity.ignoring()} sound like synonyms and are not.
*
* <p>{@code permitAll()} runs the whole chain and then authorizes the request. Security headers
* are written, CSRF is enforced, the {@code SecurityContext} is loaded, and
* {@code AuthorizationFilter} says yes.
*
* <p>{@code ignoring()} creates a {@code SecurityFilterChain} with <strong>zero filters</strong>.
* You can see it in {@code /diag/chains} as a real, matched, empty chain. No headers, no CSRF,
* no context, no anything. It is faster and it is a footgun: anything served under that path is
* outside Spring Security entirely.
*
* @see <a href="../../../../../../../docs/07-multiple-chains.md">docs/07-multiple-chains.md</a>
*/
@Configuration
@Profile("ignoring")
public class IgnoringSecurityConfig {
@Bean
WebSecurityCustomizer ignoreStatic() {
return (web) -> web.ignoring().requestMatchers("/static/**");
}
@Bean
SecurityFilterChain ignoring(HttpSecurity http) throws Exception {
http
.csrf(Customizer.withDefaults())
.httpBasic(Customizer.withDefaults())
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/public/**", "/diag/**").permitAll()
.anyRequest().authenticated());
return http.build();
}
}

View File

@@ -0,0 +1,44 @@
package com.ankurm.chain.config;
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.access.intercept.AuthorizationFilter;
import com.ankurm.chain.filter.ApiKeyAuthenticationFilter;
/**
* The same {@link ApiKeyAuthenticationFilter} as the {@code custom} profile, moved <em>one
* slot</em> to the wrong side of {@code AuthorizationFilter}.
*
* <p>{@code addFilterAfter(.., AuthorizationFilter.class)} gives it order 4201. The
* authorization decision has already been taken by then, against whatever principal was in the
* holder &mdash; the anonymous one. A valid key produces a 401, the filter runs anyway, and the
* only visible symptom is a rejection that the logs blame on authentication.
*
* <p>This is the single most common filter-ordering bug and it does not announce itself:
* there is no warning, no exception, and the filter demonstrably executes.
*
* @see <a href="../../../../../../../docs/05-failure-modes.md">docs/05-failure-modes.md</a>
*/
@Configuration
@Profile("misordered")
public class MisorderedSecurityConfig {
@Bean
SecurityFilterChain misordered(HttpSecurity http) throws Exception {
http
.csrf((csrf) -> csrf.disable())
// Identical to the `custom` profile in every respect except the anchor below.
.httpBasic(org.springframework.security.config.Customizer.withDefaults())
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/public/**", "/diag/**").permitAll()
.anyRequest().authenticated())
.addFilterAfter(new ApiKeyAuthenticationFilter(), AuthorizationFilter.class);
return http.build();
}
}

View File

@@ -0,0 +1,65 @@
package com.ankurm.chain.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
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.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
/**
* Three chains, one application. The thing to look at in {@code /diag/chains} is that they are
* not the same length: an API chain that turns off sessions, CSRF and form login is materially
* shorter than the browser chain, and every filter it drops is a filter that cannot surprise you.
*
* <p>{@code @Order} on the bean is what fixes evaluation order, and evaluation stops at the
* first match. Getting {@code @Order} wrong &mdash; or omitting it, which gives
* {@code Ordered.LOWEST_PRECEDENCE} and an unspecified relative order &mdash; is how a broad
* chain ends up shadowing a narrow one. Since Spring Security 6.5 a chain that can never be
* reached fails the context at startup rather than being silently dead; the message names the
* shadowing chain.
*
* @see <a href="../../../../../../../docs/07-multiple-chains.md">docs/07-multiple-chains.md</a>
*/
@Configuration
@Profile("multichain")
public class MultiChainSecurityConfig {
/** Stateless, token-ish. No session, no CSRF, no login page, no logout. */
@Bean
@Order(1)
SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.csrf((csrf) -> csrf.disable())
.httpBasic(Customizer.withDefaults())
.sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated());
return http.build();
}
/** Diagnostics, wide open, but still a full chain - contrast with the ignoring profile. */
@Bean
@Order(2)
SecurityFilterChain diagChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/diag/**", "/public/**")
.csrf((csrf) -> csrf.disable())
.authorizeHttpRequests((authorize) -> authorize.anyRequest().permitAll());
return http.build();
}
/** Everything else: a browser chain with sessions, CSRF and a generated login page. */
@Bean
@Order(3)
SecurityFilterChain browserChain(HttpSecurity http) throws Exception {
http
.csrf(Customizer.withDefaults())
.formLogin(Customizer.withDefaults())
.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated());
return http.build();
}
}

View File

@@ -0,0 +1,47 @@
package com.ankurm.chain.config;
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;
import org.springframework.security.web.csrf.CsrfFilter;
import com.ankurm.chain.filter.MarkerFilterA;
import com.ankurm.chain.filter.MarkerFilterB;
/**
* Two filters, one anchor, one order number.
*
* <p>{@code addFilterBefore} computes {@code anchorOrder - 1}, not &ldquo;a slot before whatever
* is already there&rdquo;. Both of these land on <strong>1099</strong>. The tie is broken by
* {@code List.sort}, which is stable, so the filters run in the order the {@code addFilterBefore}
* calls were made &mdash; a guarantee that comes from {@code java.util.List}, not from anything
* Spring Security documents.
*
* <p>The {@code TIE_REVERSED} system property flips the two calls, which flips the executed
* order. That diff is the proof.
*
* @see <a href="../../../../../../../docs/04-where-custom-filters-land.md">docs/04-where-custom-filters-land.md</a>
*/
@Configuration
@Profile("tie")
public class TieSecurityConfig {
@Bean
SecurityFilterChain tie(HttpSecurity http) throws Exception {
http
.csrf((csrf) -> csrf.disable())
.authorizeHttpRequests((authorize) -> authorize.anyRequest().permitAll());
if (Boolean.getBoolean("TIE_REVERSED")) {
http.addFilterBefore(new MarkerFilterB("B"), CsrfFilter.class);
http.addFilterBefore(new MarkerFilterA("A"), CsrfFilter.class);
}
else {
http.addFilterBefore(new MarkerFilterA("A"), CsrfFilter.class);
http.addFilterBefore(new MarkerFilterB("B"), CsrfFilter.class);
}
return http.build();
}
}

View File

@@ -0,0 +1,23 @@
package com.ankurm.chain.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;
/**
* Two in-memory users, shared by every profile. {@code {noop}} passwords because the subject
* of this module is filter ordering, not password storage.
*/
@Configuration
public class UsersConfig {
@Bean
UserDetailsService users() {
return new InMemoryUserDetailsManager(
User.withUsername("alice").password("{noop}password").roles("USER").build(),
User.withUsername("root").password("{noop}password").roles("ADMIN").build());
}
}

View File

@@ -0,0 +1,55 @@
package com.ankurm.chain.filter;
import java.io.IOException;
import java.util.List;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
/**
* The shape almost every real project ends up writing: read a credential off the request,
* build an {@code Authentication}, put it in the {@code SecurityContextHolder}, carry on.
*
* <p>Two details matter more than the parsing.
*
* <p><strong>It must run before {@code AuthorizationFilter} (4200).</strong> That is the
* whole reason placement is a topic. The {@code misordered} profile adds this same filter
* <em>after</em> {@code AuthorizationFilter} and the result is a 401 with a perfectly valid
* key &mdash; the authorization decision was already taken against an anonymous principal.
*
* <p><strong>It creates the context with {@code createEmptyContext()} rather than mutating the
* one already in the holder.</strong> Since Spring Security 6, {@code SecurityContextHolderFilter}
* does not save the context back to the session, so mutating the existing instance is both a
* shared-state hazard and pointless.
*
* @see <a href="../../../../../../../docs/04-where-custom-filters-land.md">docs/04-where-custom-filters-land.md</a>
* @see <a href="../../../../../../../docs/05-failure-modes.md">docs/05-failure-modes.md</a>
*/
public class ApiKeyAuthenticationFilter extends OncePerRequestFilter {
/** Not a credential store. The point of this class is the placement, not the parsing. */
private static final String VALID_KEY = "let-me-in";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String key = request.getHeader("X-Api-Key");
if (VALID_KEY.equals(key)) {
UsernamePasswordAuthenticationToken authentication = UsernamePasswordAuthenticationToken
.authenticated("api-client", null, List.of(new SimpleGrantedAuthority("ROLE_API")));
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
}
chain.doFilter(request, response);
}
}

View File

@@ -0,0 +1,54 @@
package com.ankurm.chain.filter;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicInteger;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.filter.GenericFilterBean;
/**
* Counts its own invocations and reports the count in a response header, so the
* double-registration trap becomes a number rather than an argument.
*
* <p>Deliberately extends {@code GenericFilterBean} rather than {@code OncePerRequestFilter}.
* {@code OncePerRequestFilter} would mask the bug: it sets a request attribute on first entry
* and short-circuits on the second, so the filter <em>is</em> still registered twice, still
* consumes a stack frame twice, and still runs twice in every dispatch that gets a fresh
* request &mdash; but the counter would read 1 and you would conclude there was no problem.
*
* @see <a href="../../../../../../../docs/05-failure-modes.md">docs/05-failure-modes.md</a>
*/
public class CountingFilter extends GenericFilterBean {
/** Total across the application's lifetime, for the assertions. */
private final AtomicInteger total = new AtomicInteger();
@Override
public void doFilter(jakarta.servlet.ServletRequest request, jakarta.servlet.ServletResponse response,
FilterChain chain) throws IOException, ServletException {
this.total.incrementAndGet();
// Per-request, so the header answers "how many times did this filter run for THIS
// request" rather than "how many requests has the application served".
int perRequest = 1;
if (request instanceof HttpServletRequest httpRequest) {
Object previous = httpRequest.getAttribute(ATTRIBUTE);
perRequest = (previous instanceof Integer count) ? count + 1 : 1;
httpRequest.setAttribute(ATTRIBUTE, perRequest);
}
if (response instanceof HttpServletResponse httpResponse) {
httpResponse.setHeader("X-Counting-Filter-Invocations", String.valueOf(perRequest));
}
chain.doFilter(request, response);
}
public static final String ATTRIBUTE = "countingFilterInvocations";
public int total() {
return this.total.get();
}
}

View File

@@ -0,0 +1,51 @@
package com.ankurm.chain.filter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.filter.OncePerRequestFilter;
/**
* Appends its label to a request-scoped list, so the <em>executed</em> order of a set of
* filters can be read off the response instead of inferred from configuration.
*
* <p>Used by the {@code tie} profile, where two filters are added before the same anchor and
* therefore receive the same order number.
*
* @see <a href="../../../../../../../docs/04-where-custom-filters-land.md">docs/04-where-custom-filters-land.md</a>
*/
public class MarkerFilter extends OncePerRequestFilter {
public static final String ATTRIBUTE = "com.ankurm.chain.markers";
private final String label;
public MarkerFilter(String label) {
this.label = label;
}
@SuppressWarnings("unchecked")
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
List<String> markers = (List<String>) request.getAttribute(ATTRIBUTE);
if (markers == null) {
markers = new ArrayList<>();
request.setAttribute(ATTRIBUTE, markers);
}
markers.add(this.label);
chain.doFilter(request, response);
}
@Override
public String toString() {
return getClass().getSimpleName() + "[" + this.label + "]";
}
}

View File

@@ -0,0 +1,14 @@
package com.ankurm.chain.filter;
/**
* A distinct class from {@link MarkerFilterB} so that both can be registered against the same
* anchor and receive the same order number without one overwriting the other in
* {@code FilterOrderRegistration}. See {@code docs/04-where-custom-filters-land.md}.
*/
public class MarkerFilterA extends MarkerFilter {
public MarkerFilterA(String label) {
super(label);
}
}

View File

@@ -0,0 +1,10 @@
package com.ankurm.chain.filter;
/** @see MarkerFilterA */
public class MarkerFilterB extends MarkerFilter {
public MarkerFilterB(String label) {
super(label);
}
}

View File

@@ -0,0 +1,38 @@
package com.ankurm.chain.filter;
import java.io.IOException;
import java.util.UUID;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.filter.OncePerRequestFilter;
/**
* A filter with no security opinion at all: it stamps a correlation id on the response.
*
* <p>It is in this repository to make one point &mdash; a filter like this does not need to be
* in the security chain. Put it in the servlet chain and it wraps <em>everything</em>, including
* the requests {@code FilterChainProxy} rejects before any of your code runs. Put it in the
* security chain and requests that fail the {@code HttpFirewall}, or that match a different
* {@code SecurityFilterChain}, never reach it.
*
* <p>Extending {@code OncePerRequestFilter} deliberately: see
* {@code docs/04-where-custom-filters-land.md} for why that class is <em>not</em> in the order
* table and what that means for {@code http.addFilter(..)}.
*
* @see <a href="../../../../../../../docs/04-where-custom-filters-land.md">docs/04-where-custom-filters-land.md</a>
*/
public class RequestIdFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
response.setHeader("X-Request-Id", UUID.nameUUIDFromBytes(
(request.getMethod() + request.getRequestURI()).getBytes()).toString());
chain.doFilter(request, response);
}
}

View File

@@ -0,0 +1,86 @@
package com.ankurm.chain.filter;
import java.io.IOException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.filter.OncePerRequestFilter;
/**
* Deliberately modelled on the {@code TenantFilter} in the Spring Security reference
* documentation, because it hides a placement trap that the documentation does not mention.
*
* <p>It signals refusal by throwing {@link AccessDeniedException}. Nothing in the servlet API
* turns that into a 403 &mdash; {@code ExceptionTranslationFilter} does, and it sits at order
* <strong>4000</strong>. Throw this exception from a filter placed at any order below 4000 and
* it propagates straight out of {@code FilterChainProxy} to the container, which renders it as
* <strong>500 Internal Server Error</strong>.
*
* <p>The documented placement (after {@code AnonymousAuthenticationFilter}, order 3701) is
* below 4000 and therefore does not produce the 403 the example implies. The {@code custom}
* profile shows both placements side by side; the captured proof is in
* {@code docs/output/demo6-exception-translation.txt}.
*
* <p>There is a second trap in putting two instances of this class in one chain, and it cost a
* test failure to find. {@code OncePerRequestFilter} guards against double execution with a
* request attribute named {@code getFilterName() + ".FILTERED"}, and for a filter that is not a
* Spring bean {@code getFilterName()} falls back to the <em>class name</em>. Two instances of
* the same {@code OncePerRequestFilter} subclass therefore share one attribute, and
* <strong>the second one silently never runs</strong>. Set {@code -DUNIQUE_ONCE_KEY=true} to
* give each instance its own key and watch the second filter come back to life.
*
* @see <a href="../../../../../../../docs/05-failure-modes.md">docs/05-failure-modes.md</a>
*/
public class TenantFilter extends OncePerRequestFilter {
private final String label;
private final String guardedPrefix;
/**
* @param label which of the two instances this is, so the captured output says which one
* threw
* @param guardedPrefix only requests under this path are checked, so that the two instances
* can coexist in one chain and be triggered independently
*/
public TenantFilter(String label, String guardedPrefix) {
this.label = label;
this.guardedPrefix = guardedPrefix;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
if (!request.getRequestURI().startsWith(this.guardedPrefix)) {
chain.doFilter(request, response);
return;
}
String tenant = request.getHeader("X-Tenant-Id");
if ("acme".equals(tenant)) {
chain.doFilter(request, response);
return;
}
throw new AccessDeniedException("tenant '" + tenant + "' rejected by " + this.label);
}
/**
* With the JVM flag set, each instance gets its own already-filtered key. Without it, both
* instances answer {@code "com.ankurm.chain.filter.TenantFilter.FILTERED"} and the second
* one to run finds the attribute already set and skips itself.
*/
@Override
protected String getAlreadyFilteredAttributeName() {
String base = super.getAlreadyFilteredAttributeName();
return Boolean.getBoolean("UNIQUE_ONCE_KEY") ? base + "." + this.label : base;
}
@Override
public String toString() {
return getClass().getSimpleName() + "[" + this.label + "]";
}
}

View File

@@ -0,0 +1,109 @@
package com.ankurm.chain.support;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import jakarta.servlet.Filter;
/**
* Reads {@code org.springframework.security.config.annotation.web.builders.FilterOrderRegistration}
* &mdash; the package-private class that decides the order of every filter in the chain.
*
* <p>It is not public API and there is no supported way to see it. Reflection is honest about
* that: the class is instantiated with its no-arg constructor and its {@code filterToOrder} map
* is read out verbatim. Nothing here is a transcription of the source; if Spring Security
* renumbers a slot, this table changes with it.
*
* <p>The map is keyed by <em>class name string</em>, not by {@code Class}, precisely because
* several entries name classes that may not be on the classpath &mdash; the optional
* {@code oauth2-client}, {@code saml2}, {@code cas} and {@code oauth2-resource-server} filters.
* As of Spring Security 7 two of those string entries name classes that no longer exist
* <em>anywhere</em>: {@code ChannelProcessingFilter} (300) and {@code FilterSecurityInterceptor}
* (4100), both removed in 7.0. Their slots were kept so that no other number had to move.
*
* @see <a href="../../../../../../../docs/03-the-order-table.md">docs/03-the-order-table.md</a>
*/
public final class FilterOrderTable {
private static final String REGISTRATION_CLASS =
"org.springframework.security.config.annotation.web.builders.FilterOrderRegistration";
private static final Map<String, Integer> TABLE = read();
private FilterOrderTable() {
}
/** The whole registration table, keyed by fully-qualified class name. */
public static Map<String, Integer> table() {
return TABLE;
}
/**
* The order Spring Security would give this filter type, resolved the same way
* {@code FilterOrderRegistration.getOrder(Class)} resolves it &mdash; by walking up the
* superclass chain until a registered type is found. That walk is why a subclass of
* {@code UsernamePasswordAuthenticationFilter} can be passed to {@code http.addFilter(..)}
* with no further configuration, and why a filter extending {@code OncePerRequestFilter}
* cannot: {@code OncePerRequestFilter} is not in the table.
*
* @return the order, or {@code null} if no ancestor of this type is registered
*/
public static Integer orderOf(Class<?> filterType) {
for (Class<?> type = filterType; type != null; type = type.getSuperclass()) {
Integer order = TABLE.get(type.getName());
if (order != null) {
return order;
}
}
return null;
}
/** Whether a name in the table resolves to a class that is actually present. */
public static boolean isOnClasspath(String className) {
try {
Class.forName(className, false, FilterOrderTable.class.getClassLoader());
return true;
}
catch (ClassNotFoundException | LinkageError ex) {
return false;
}
}
/**
* What {@code http.addFilterBefore(filter, anchor)} would compute, so a test can assert the
* number rather than eyeball the chain. {@code addFilterAtOffsetOf} adds the offset to the
* anchor's registered order: -1 for before, +1 for after, 0 for at.
*/
public static Integer orderIfAddedBefore(Class<? extends Filter> anchor) {
Integer anchorOrder = orderOf(anchor);
return (anchorOrder != null) ? anchorOrder - 1 : null;
}
/** @see #orderIfAddedBefore(Class) */
public static Integer orderIfAddedAfter(Class<? extends Filter> anchor) {
Integer anchorOrder = orderOf(anchor);
return (anchorOrder != null) ? anchorOrder + 1 : null;
}
@SuppressWarnings("unchecked")
private static Map<String, Integer> read() {
try {
Class<?> type = Class.forName(REGISTRATION_CLASS);
Constructor<?> constructor = type.getDeclaredConstructor();
constructor.setAccessible(true);
Object registration = constructor.newInstance();
Field field = type.getDeclaredField("filterToOrder");
field.setAccessible(true);
return Collections.unmodifiableMap(new LinkedHashMap<>((Map<String, Integer>) field.get(registration)));
}
catch (ReflectiveOperationException ex) {
throw new IllegalStateException("Could not read " + REGISTRATION_CLASS
+ ". If this fails on a future Spring Security version, the class or its "
+ "field was renamed - which is itself worth knowing.", ex);
}
}
}

View File

@@ -0,0 +1,75 @@
package com.ankurm.chain.web;
import java.util.List;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ankurm.chain.filter.MarkerFilter;
/**
* Endpoints the scripts drive. Each one exists to make one thing observable.
*/
@RestController
public class DemoController {
/** Who the chain decided you are, by the time a controller runs. */
@GetMapping(value = "/whoami", produces = "text/plain")
public String whoami(HttpServletRequest request) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null) {
return "authentication=null (no filter put one in the holder)\n";
}
return "principal=" + authentication.getName()
+ " authenticated=" + authentication.isAuthenticated()
+ " authorities=" + authentication.getAuthorities()
+ " type=" + authentication.getClass().getSimpleName()
+ " counting-filter-invocations=" + request.getAttribute("countingFilterInvocations") + "\n";
}
@GetMapping(value = "/public/hello", produces = "text/plain")
public String publicHello() {
return "public ok\n";
}
@GetMapping(value = "/api/data", produces = "text/plain")
public String apiData() {
return "api ok\n";
}
@GetMapping(value = "/static/asset.txt", produces = "text/plain")
public String staticAsset() {
return "static ok\n";
}
@PostMapping(value = "/hello", produces = "text/plain")
public String postHello() {
return "post ok\n";
}
/** Guarded by the TenantFilter placed BELOW ExceptionTranslationFilter (order 3701). */
@GetMapping(value = "/tenant/doc", produces = "text/plain")
public String tenantDoc() {
return "tenant/doc ok\n";
}
/** Guarded by the TenantFilter placed ABOVE ExceptionTranslationFilter (order 4001). */
@GetMapping(value = "/tenant/translated", produces = "text/plain")
public String tenantTranslated() {
return "tenant/translated ok\n";
}
/** The executed order of the two tied marker filters, read off the request. */
@SuppressWarnings("unchecked")
@GetMapping(value = "/markers", produces = "text/plain")
public String markers(HttpServletRequest request) {
List<String> markers = (List<String>) request.getAttribute(MarkerFilter.ATTRIBUTE);
return "executed order: " + markers + "\n";
}
}

View File

@@ -0,0 +1,145 @@
package com.ankurm.chain.web;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterRegistration;
import jakarta.servlet.ServletContext;
import org.springframework.security.web.DefaultSecurityFilterChain;
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 com.ankurm.chain.support.FilterOrderTable;
/**
* Prints the security plumbing that is otherwise invisible at runtime.
*
* <p>Three views, and they answer three different questions:
*
* <ul>
* <li>{@code /diag/chains} &mdash; what {@link FilterChainProxy} actually holds. This is the
* only authoritative answer to &ldquo;which filters run for my request&rdquo;, because it
* is read back out of the live bean rather than inferred from configuration.</li>
* <li>{@code /diag/order} &mdash; the {@code FilterOrderRegistration} table, reflected out of
* {@code spring-security-config}. This is the sort key {@code HttpSecurity} uses, and it
* is what determines where {@code addFilterBefore(..)} puts your filter.</li>
* <li>{@code /diag/servlet-filters} &mdash; what the servlet container has registered. A
* custom security filter that appears here <em>as well as</em> in {@code /diag/chains}
* runs twice per request.</li>
* </ul>
*
* <p><strong>Delete this controller before shipping.</strong> It publishes your entire security
* topology to anyone who can reach it.
*
* @see <a href="../../../../../../../docs/02-the-default-chain.md">docs/02-the-default-chain.md</a>
* @see <a href="../../../../../../../docs/04-where-custom-filters-land.md">docs/04-where-custom-filters-land.md</a>
*/
@RestController
public class DiagnosticsController {
private final FilterChainProxy filterChainProxy;
private final ServletContext servletContext;
public DiagnosticsController(FilterChainProxy filterChainProxy, ServletContext servletContext) {
this.filterChainProxy = filterChainProxy;
this.servletContext = servletContext;
}
/**
* Every {@link SecurityFilterChain} the proxy holds, in the order it evaluates them, with
* each chain's filters in the order they will run.
*
* <p>Note the numbering: it matches the {@code (n/m)} counters in {@code FilterChainProxy}'s
* TRACE output exactly, because both come from the same list.
*/
@GetMapping(value = "/diag/chains", produces = "text/plain")
public String chains() {
StringBuilder out = new StringBuilder();
List<SecurityFilterChain> chains = this.filterChainProxy.getFilterChains();
out.append("FilterChainProxy holds ").append(chains.size()).append(" SecurityFilterChain(s).\n");
out.append("The FIRST chain whose matcher accepts the request wins; the rest are never consulted.\n");
int chainIndex = 0;
for (SecurityFilterChain chain : chains) {
chainIndex++;
List<Filter> filters = chain.getFilters();
out.append("\n=== chain ").append(chainIndex).append('/').append(chains.size())
.append(" matcher = ").append(describeMatcher(chain))
.append(" (").append(filters.size()).append(" filters)\n");
if (filters.isEmpty()) {
out.append(" <no filters> - this chain does NOTHING. Requests it matches are\n");
out.append(" unauthenticated, unauthorised, and get no security headers.\n");
continue;
}
int i = 0;
for (Filter filter : filters) {
i++;
String name = filter.getClass().getSimpleName();
Integer order = FilterOrderTable.orderOf(filter.getClass());
out.append(String.format(" %2d/%d %-52s %s%n", i, filters.size(), name,
(order != null) ? "order=" + order : "order=? (not in the registration table)"));
}
}
return out.toString();
}
/**
* The registration table itself. Orders 300 and 4100 are printed even though the classes
* they name were removed in Spring Security 7 &mdash; see
* {@code docs/03-the-order-table.md}.
*/
@GetMapping(value = "/diag/order", produces = "text/plain")
public String order() {
StringBuilder out = new StringBuilder();
out.append("FilterOrderRegistration, reflected out of spring-security-config.\n");
out.append("Slots are 100 apart, starting at 100. addFilterBefore = slot-1, addFilterAfter = slot+1.\n\n");
Map<String, Integer> table = FilterOrderTable.table();
List<Map.Entry<String, Integer>> entries = new ArrayList<>(table.entrySet());
entries.sort(Comparator.comparing(Map.Entry::getValue));
int expected = 100;
for (Map.Entry<String, Integer> entry : entries) {
while (expected < entry.getValue()) {
out.append(String.format(" %4d %s%n", expected, "-- reserved, nothing registered --"));
expected += 100;
}
boolean present = FilterOrderTable.isOnClasspath(entry.getKey());
out.append(String.format(" %4d %-88s %s%n", entry.getValue(), entry.getKey(),
present ? "" : "<-- CLASS NOT ON CLASSPATH"));
expected = entry.getValue() + 100;
}
return out.toString();
}
/**
* What the servlet container itself has registered, ahead of and around
* {@code FilterChainProxy}. The security chain is a single entry here
* (&ldquo;springSecurityFilterChain&rdquo;, a {@code DelegatingFilterProxy}); everything
* inside it is invisible to the container.
*/
@GetMapping(value = "/diag/servlet-filters", produces = "text/plain")
public String servletFilters() {
StringBuilder out = new StringBuilder("Filters registered with the servlet container:\n\n");
Map<String, ? extends FilterRegistration> registrations = this.servletContext.getFilterRegistrations();
registrations.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.forEach((entry) -> out.append(String.format(" %-46s %s urls=%s%n", entry.getKey(),
entry.getValue().getClassName(), entry.getValue().getUrlPatternMappings())));
out.append("\nAnything listed here runs OUTSIDE FilterChainProxy. A custom security filter that\n");
out.append("appears both here and in /diag/chains will run twice for every request.\n");
return out.toString();
}
private String describeMatcher(SecurityFilterChain chain) {
if (chain instanceof DefaultSecurityFilterChain defaultChain) {
return String.valueOf(defaultChain.getRequestMatcher());
}
return chain.getClass().getSimpleName();
}
}

View File

@@ -0,0 +1,15 @@
# Baseline is the reference configuration; every other scenario is an explicit profile.
spring.profiles.default=baseline
# Stable output: no banner, no random port, no startup timing noise in the captured files.
spring.main.banner-mode=off
server.port=8080
# The chain that Spring Security prints at startup is a DEBUG log on
# o.s.s.web.DefaultSecurityFilterChain; per-request filter invocations are TRACE on
# o.s.security.web.FilterChainProxy. scripts/run.sh raises this to TRACE on demand.
logging.level.org.springframework.security=INFO
logging.pattern.console=%-5level %logger{39} : %msg%n
# Actuator is on the classpath for the observation demo only; keep its endpoints out of the way.
management.endpoints.web.exposure.include=health

View File

@@ -0,0 +1,280 @@
package com.ankurm.chain;
import java.util.List;
import jakarta.servlet.Filter;
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.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* The claims the article makes about this application, pinned as assertions.
*
* <p>Each nested class is one profile, because profiles are fixed at context startup. The
* captured transcripts in {@code docs/output/} come from the same profiles driven over HTTP;
* these tests are the machine-checkable version of the same facts.
*
* @see <a href="../../../../../../docs/09-testing-the-chain.md">docs/09-testing-the-chain.md</a>
*/
class FilterChainContractTest {
private static List<String> filterNames(SecurityFilterChain chain) {
return chain.getFilters().stream().map((filter) -> filter.getClass().getSimpleName()).toList();
}
@Nested
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("baseline")
@DisplayName("the reference configuration")
class Baseline {
@Autowired
FilterChainProxy proxy;
@Test
@DisplayName("produces exactly sixteen filters, in this order")
void defaultChainIsSixteenFilters() {
assertThat(this.proxy.getFilterChains()).hasSize(1);
assertThat(filterNames(this.proxy.getFilterChains().get(0))).containsExactly(
"DisableEncodeUrlFilter",
"WebAsyncManagerIntegrationFilter",
"SecurityContextHolderFilter",
"HeaderWriterFilter",
"CsrfFilter",
"LogoutFilter",
"UsernamePasswordAuthenticationFilter",
"DefaultResourcesFilter",
"DefaultLoginPageGeneratingFilter",
"DefaultLogoutPageGeneratingFilter",
"BasicAuthenticationFilter",
"RequestCacheAwareFilter",
"SecurityContextHolderAwareRequestFilter",
"AnonymousAuthenticationFilter",
"ExceptionTranslationFilter",
"AuthorizationFilter");
}
@Test
@DisplayName("does NOT contain SessionManagementFilter, which many articles still list")
void sessionManagementFilterIsAbsentByDefault() {
assertThat(filterNames(this.proxy.getFilterChains().get(0)))
.doesNotContain("SessionManagementFilter", "SecurityContextPersistenceFilter");
}
@Test
@DisplayName("every filter in the chain resolves to a slot in the order table, ascending")
void chainIsSortedByTheOrderTable() {
List<Filter> filters = this.proxy.getFilterChains().get(0).getFilters();
List<Integer> orders = filters.stream()
.map((filter) -> com.ankurm.chain.support.FilterOrderTable.orderOf(filter.getClass()))
.toList();
assertThat(orders).doesNotContainNull().isSorted();
}
}
@Nested
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("custom")
@DisplayName("custom filters")
class Custom {
@Autowired
FilterChainProxy proxy;
@Autowired
MockMvc mvc;
@Test
@DisplayName("land exactly where their anchor's order puts them")
void customFiltersLandNextToTheirAnchors() {
List<String> names = filterNames(this.proxy.getFilterChains().get(0));
assertThat(names).containsSubsequence("SecurityContextHolderFilter", "RequestIdFilter", "HeaderWriterFilter");
assertThat(names).containsSubsequence("LogoutFilter", "ApiKeyAuthenticationFilter",
"UsernamePasswordAuthenticationFilter");
assertThat(names).containsSubsequence("AnonymousAuthenticationFilter", "TenantFilter",
"ExceptionTranslationFilter", "TenantFilter", "AuthorizationFilter");
}
@Test
@DisplayName("an AccessDeniedException thrown BELOW ExceptionTranslationFilter is not translated")
void accessDeniedBelowTranslationIsNotTranslated() {
// The exception comes straight back out of FilterChainProxy with no cause and no
// status code. In a real container that is a 500; MockMvc shows you the throw
// itself, which is the same fact seen from the other side. The HTTP-level proof is
// docs/output/demo6-exception-translation.txt.
assertThat(org.assertj.core.api.Assertions.catchThrowable(
() -> this.mvc.perform(get("/tenant/doc").with(user()))))
.isInstanceOf(org.springframework.security.access.AccessDeniedException.class)
.hasMessageContaining("doc-placement");
}
@Test
@DisplayName("the second instance of the same OncePerRequestFilter subclass never runs")
void twoInstancesOfOneOncePerRequestFilterShareTheirAlreadyFilteredKey() throws Exception {
// Both TenantFilters are in the chain - customFiltersLandNextToTheirAnchors proves
// it. But they answer the same getAlreadyFilteredAttributeName(), so the one at
// order 3701 marks the request and the one at 4001 skips itself. The guarded path
// sails through with a 200. Run with -DUNIQUE_ONCE_KEY=true and this becomes a 403.
assertThat(System.getProperty("UNIQUE_ONCE_KEY")).isNull();
this.mvc.perform(get("/tenant/translated").with(user())).andExpect(status().isOk());
}
@Test
@DisplayName("the api-key filter at order 1201 authenticates before authorization runs")
void apiKeyFilterAuthenticatesInTime() throws Exception {
this.mvc.perform(get("/whoami").header("X-Api-Key", "let-me-in"))
.andExpect(status().isOk());
}
@Test
@DisplayName("the request-id filter at order 701 runs for permitted requests too")
void requestIdFilterRuns() throws Exception {
this.mvc.perform(get("/public/hello")).andExpect(header().exists("X-Request-Id"));
}
private static org.springframework.test.web.servlet.request.RequestPostProcessor user() {
return org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
.user("alice").roles("USER");
}
}
@Nested
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("misordered")
@DisplayName("an authentication filter after AuthorizationFilter")
class Misordered {
@Autowired
MockMvc mvc;
@Autowired
FilterChainProxy proxy;
@Test
@DisplayName("rejects a valid credential, because the decision was already taken")
void validKeyIsStillRejected() throws Exception {
this.mvc.perform(get("/whoami").header("X-Api-Key", "let-me-in"))
.andExpect(status().isUnauthorized());
}
@Test
@DisplayName("and the filter demonstrably did run - it is in the chain, one slot too late")
void theFilterIsStillInTheChain() {
assertThat(this.proxy.getFilterChains().get(0).getFilters().stream()
.map((filter) -> filter.getClass().getSimpleName()).toList())
.containsSubsequence("AuthorizationFilter", "ApiKeyAuthenticationFilter");
}
}
@Nested
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("tie")
@DisplayName("two filters added before the same anchor")
class Tie {
@Autowired
FilterChainProxy proxy;
@Test
@DisplayName("run in the order they were added")
void tiedFiltersKeepRegistrationOrder() {
assertThat(filterNames(this.proxy.getFilterChains().get(0)))
.containsSubsequence("MarkerFilterA", "MarkerFilterB");
}
@Test
@DisplayName("land at the anchor's slot even though the anchor is not in the chain")
void theAnchorNeedNotBePresent() {
// This profile calls csrf().disable(), so there is no CsrfFilter to be 'before'.
// addFilterBefore resolves the anchor against the STATIC order table, not against
// the chain being built, so both filters still land on 1099 - between
// HeaderWriterFilter (900) and LogoutFilter (1200).
List<String> names = filterNames(this.proxy.getFilterChains().get(0));
assertThat(names).doesNotContain("CsrfFilter");
assertThat(names).containsSubsequence("HeaderWriterFilter", "MarkerFilterA", "MarkerFilterB",
"LogoutFilter");
}
}
@Nested
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("ignoring")
@DisplayName("WebSecurity.ignoring()")
class Ignoring {
@Autowired
FilterChainProxy proxy;
@Autowired
MockMvc mvc;
@Test
@DisplayName("creates a real chain with zero filters")
void ignoringCreatesAnEmptyChain() {
List<SecurityFilterChain> chains = this.proxy.getFilterChains();
assertThat(chains).hasSizeGreaterThan(1);
SecurityFilterChain first = chains.get(0);
assertThat(first.getFilters()).isEmpty();
assertThat(first).isInstanceOf(DefaultSecurityFilterChain.class);
}
@Test
@DisplayName("means no security headers at all on the ignored path")
void ignoredPathGetsNoSecurityHeaders() throws Exception {
this.mvc.perform(get("/static/asset.txt"))
.andExpect(status().isOk())
.andExpect(header().doesNotExist("X-Content-Type-Options"));
this.mvc.perform(get("/public/hello"))
.andExpect(status().isOk())
.andExpect(header().string("X-Content-Type-Options", "nosniff"));
}
}
@Nested
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("multichain")
@DisplayName("three SecurityFilterChain beans")
class MultiChain {
@Autowired
FilterChainProxy proxy;
@Test
@DisplayName("are evaluated in @Order and the api chain is materially shorter")
void chainsAreOrderedAndDifferentLengths() {
List<SecurityFilterChain> chains = this.proxy.getFilterChains();
assertThat(chains).hasSize(3);
assertThat(chains.get(0).getFilters().size()).isLessThan(chains.get(2).getFilters().size());
assertThat(filterNames(chains.get(0))).doesNotContain("CsrfFilter", "DefaultLoginPageGeneratingFilter");
assertThat(filterNames(chains.get(2))).contains("CsrfFilter", "DefaultLoginPageGeneratingFilter");
}
}
}

View File

@@ -0,0 +1,81 @@
package com.ankurm.chain;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.security.web.access.ExceptionTranslationFilter;
import org.springframework.security.web.access.intercept.AuthorizationFilter;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.csrf.CsrfFilter;
import org.springframework.security.web.session.DisableEncodeUrlFilter;
import org.springframework.web.filter.OncePerRequestFilter;
import com.ankurm.chain.filter.RequestIdFilter;
import com.ankurm.chain.support.FilterOrderTable;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Facts about the ordering table itself. No application context: these are properties of
* {@code spring-security-config}, not of this application.
*
* @see <a href="../../../../../../docs/03-the-order-table.md">docs/03-the-order-table.md</a>
*/
class FilterOrderTableTest {
@Test
@DisplayName("slots are 100 apart and start at 100")
void slotsAreOneHundredApart() {
assertThat(FilterOrderTable.orderOf(DisableEncodeUrlFilter.class)).isEqualTo(100);
assertThat(FilterOrderTable.orderOf(CsrfFilter.class)).isEqualTo(1100);
assertThat(FilterOrderTable.orderOf(ExceptionTranslationFilter.class)).isEqualTo(4000);
assertThat(FilterOrderTable.orderOf(AuthorizationFilter.class)).isEqualTo(4200);
assertThat(FilterOrderTable.table().values()).allSatisfy((order) -> assertThat(order % 100).isZero());
}
@Test
@DisplayName("addFilterBefore is anchor-1 and addFilterAfter is anchor+1, not 'the next free slot'")
void offsetsAreExactlyOne() {
assertThat(FilterOrderTable.orderIfAddedBefore(CsrfFilter.class)).isEqualTo(1099);
assertThat(FilterOrderTable.orderIfAddedAfter(CsrfFilter.class)).isEqualTo(1101);
}
@Test
@DisplayName("getOrder walks up the superclass chain")
void resolutionWalksSuperclasses() {
class MyLoginFilter extends UsernamePasswordAuthenticationFilter {
}
assertThat(FilterOrderTable.orderOf(MyLoginFilter.class)).isEqualTo(2100);
}
@Test
@DisplayName("OncePerRequestFilter is NOT in the table, so a plain custom filter has no order")
void oncePerRequestFilterIsNotRegistered() {
assertThat(FilterOrderTable.table()).doesNotContainKey(OncePerRequestFilter.class.getName());
assertThat(FilterOrderTable.orderOf(RequestIdFilter.class)).isNull();
}
@Test
@DisplayName("two slots are reserved and hold nothing: 500 and 2300")
void reservedSlotsAreEmpty() {
assertThat(FilterOrderTable.table().values()).doesNotContain(500, 2300);
}
@Test
@DisplayName("orders 300 and 4100 name classes that no longer exist in Spring Security 7")
void removedFiltersStillOccupyTheirSlots() {
Map<String, Integer> table = FilterOrderTable.table();
String channel = "org.springframework.security.web.access.channel.ChannelProcessingFilter";
String interceptor = "org.springframework.security.web.access.intercept.FilterSecurityInterceptor";
assertThat(table).containsEntry(channel, 300).containsEntry(interceptor, 4100);
// Both live in spring-security-web, which IS on the classpath - so their absence is a
// removal, not a missing optional module.
assertThat(FilterOrderTable.isOnClasspath(channel)).isFalse();
assertThat(FilterOrderTable.isOnClasspath(interceptor)).isFalse();
assertThat(FilterOrderTable.isOnClasspath(AuthorizationFilter.class.getName())).isTrue();
}
}

View File

@@ -71,6 +71,6 @@ nothing translates them and they surface raw.
received the context, method security does not fail open; it throws
`AuthenticationCredentialsNotFoundException`. Getting the context onto that thread is a
separate topic with its own module in this repository — see
[`../context-propagation/`](../context-propagation/README.md).
[`../context-propagation/`](../../context-propagation/README.md).
[← chapter index](README.md) · [next: the SpEL reference →](02-spel-reference.md)