From 73ab67b1715ccb5d5f5aab861d1253983e9e9e58 Mon Sep 17 00:00:00 2001 From: asmhatre Date: Wed, 26 Aug 2026 01:59:35 +0000 Subject: [PATCH] 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. --- README.md | 26 +- filter-chain/README.md | 118 ++++++++ filter-chain/docs/01-the-two-proxies.md | 93 ++++++ filter-chain/docs/02-the-default-chain.md | 69 +++++ filter-chain/docs/03-the-order-table.md | 117 ++++++++ .../docs/04-where-custom-filters-land.md | 112 +++++++ filter-chain/docs/05-failure-modes.md | 166 +++++++++++ filter-chain/docs/06-reading-the-trace.md | 163 ++++++++++ filter-chain/docs/07-multiple-chains.md | 71 +++++ filter-chain/docs/08-debugging-recipes.md | 101 +++++++ filter-chain/docs/09-testing-the-chain.md | 83 ++++++ filter-chain/docs/README.md | 39 +++ .../docs/output/demo1-order-table.txt | 52 ++++ .../docs/output/demo10-multichain.txt | 57 ++++ .../output/demo11-ignoring-vs-permitall.txt | 38 +++ .../docs/output/demo12-servlet-filters.txt | 17 ++ .../docs/output/demo2-default-chain.txt | 29 ++ .../docs/output/demo3-trace-authenticated.txt | 53 ++++ .../docs/output/demo4-trace-csrf-403.txt | 144 +++++++++ .../docs/output/demo5-custom-placement.txt | 30 ++ .../output/demo6-exception-translation.txt | 34 +++ filter-chain/docs/output/demo7-misordered.txt | 27 ++ filter-chain/docs/output/demo8-tie.txt | 23 ++ .../docs/output/demo9-double-registration.txt | 20 ++ filter-chain/docs/output/tests.txt | 114 +++++++ filter-chain/pom.xml | 67 +++++ filter-chain/scripts/run-all.sh | 230 ++++++++++++++ filter-chain/scripts/run.sh | 38 +++ filter-chain/scripts/stop.sh | 11 + .../chain/FilterChainDemoApplication.java | 28 ++ .../chain/config/BaselineSecurityConfig.java | 43 +++ .../config/CustomFiltersSecurityConfig.java | 61 ++++ .../config/DoubleRegistrationConfig.java | 59 ++++ .../chain/config/IgnoringSecurityConfig.java | 45 +++ .../config/MisorderedSecurityConfig.java | 44 +++ .../config/MultiChainSecurityConfig.java | 65 ++++ .../chain/config/TieSecurityConfig.java | 47 +++ .../com/ankurm/chain/config/UsersConfig.java | 23 ++ .../filter/ApiKeyAuthenticationFilter.java | 55 ++++ .../ankurm/chain/filter/CountingFilter.java | 54 ++++ .../com/ankurm/chain/filter/MarkerFilter.java | 51 ++++ .../ankurm/chain/filter/MarkerFilterA.java | 14 + .../ankurm/chain/filter/MarkerFilterB.java | 10 + .../ankurm/chain/filter/RequestIdFilter.java | 38 +++ .../com/ankurm/chain/filter/TenantFilter.java | 86 ++++++ .../chain/support/FilterOrderTable.java | 109 +++++++ .../com/ankurm/chain/web/DemoController.java | 75 +++++ .../chain/web/DiagnosticsController.java | 145 +++++++++ .../src/main/resources/application.properties | 15 + .../ankurm/chain/FilterChainContractTest.java | 280 ++++++++++++++++++ .../ankurm/chain/FilterOrderTableTest.java | 81 +++++ .../docs/01-how-method-security-runs.md | 2 +- 52 files changed, 3562 insertions(+), 10 deletions(-) create mode 100644 filter-chain/README.md create mode 100644 filter-chain/docs/01-the-two-proxies.md create mode 100644 filter-chain/docs/02-the-default-chain.md create mode 100644 filter-chain/docs/03-the-order-table.md create mode 100644 filter-chain/docs/04-where-custom-filters-land.md create mode 100644 filter-chain/docs/05-failure-modes.md create mode 100644 filter-chain/docs/06-reading-the-trace.md create mode 100644 filter-chain/docs/07-multiple-chains.md create mode 100644 filter-chain/docs/08-debugging-recipes.md create mode 100644 filter-chain/docs/09-testing-the-chain.md create mode 100644 filter-chain/docs/README.md create mode 100644 filter-chain/docs/output/demo1-order-table.txt create mode 100644 filter-chain/docs/output/demo10-multichain.txt create mode 100644 filter-chain/docs/output/demo11-ignoring-vs-permitall.txt create mode 100644 filter-chain/docs/output/demo12-servlet-filters.txt create mode 100644 filter-chain/docs/output/demo2-default-chain.txt create mode 100644 filter-chain/docs/output/demo3-trace-authenticated.txt create mode 100644 filter-chain/docs/output/demo4-trace-csrf-403.txt create mode 100644 filter-chain/docs/output/demo5-custom-placement.txt create mode 100644 filter-chain/docs/output/demo6-exception-translation.txt create mode 100644 filter-chain/docs/output/demo7-misordered.txt create mode 100644 filter-chain/docs/output/demo8-tie.txt create mode 100644 filter-chain/docs/output/demo9-double-registration.txt create mode 100644 filter-chain/docs/output/tests.txt create mode 100644 filter-chain/pom.xml create mode 100755 filter-chain/scripts/run-all.sh create mode 100755 filter-chain/scripts/run.sh create mode 100755 filter-chain/scripts/stop.sh create mode 100644 filter-chain/src/main/java/com/ankurm/chain/FilterChainDemoApplication.java create mode 100644 filter-chain/src/main/java/com/ankurm/chain/config/BaselineSecurityConfig.java create mode 100644 filter-chain/src/main/java/com/ankurm/chain/config/CustomFiltersSecurityConfig.java create mode 100644 filter-chain/src/main/java/com/ankurm/chain/config/DoubleRegistrationConfig.java create mode 100644 filter-chain/src/main/java/com/ankurm/chain/config/IgnoringSecurityConfig.java create mode 100644 filter-chain/src/main/java/com/ankurm/chain/config/MisorderedSecurityConfig.java create mode 100644 filter-chain/src/main/java/com/ankurm/chain/config/MultiChainSecurityConfig.java create mode 100644 filter-chain/src/main/java/com/ankurm/chain/config/TieSecurityConfig.java create mode 100644 filter-chain/src/main/java/com/ankurm/chain/config/UsersConfig.java create mode 100644 filter-chain/src/main/java/com/ankurm/chain/filter/ApiKeyAuthenticationFilter.java create mode 100644 filter-chain/src/main/java/com/ankurm/chain/filter/CountingFilter.java create mode 100644 filter-chain/src/main/java/com/ankurm/chain/filter/MarkerFilter.java create mode 100644 filter-chain/src/main/java/com/ankurm/chain/filter/MarkerFilterA.java create mode 100644 filter-chain/src/main/java/com/ankurm/chain/filter/MarkerFilterB.java create mode 100644 filter-chain/src/main/java/com/ankurm/chain/filter/RequestIdFilter.java create mode 100644 filter-chain/src/main/java/com/ankurm/chain/filter/TenantFilter.java create mode 100644 filter-chain/src/main/java/com/ankurm/chain/support/FilterOrderTable.java create mode 100644 filter-chain/src/main/java/com/ankurm/chain/web/DemoController.java create mode 100644 filter-chain/src/main/java/com/ankurm/chain/web/DiagnosticsController.java create mode 100644 filter-chain/src/main/resources/application.properties create mode 100644 filter-chain/src/test/java/com/ankurm/chain/FilterChainContractTest.java create mode 100644 filter-chain/src/test/java/com/ankurm/chain/FilterOrderTableTest.java diff --git a/README.md b/README.md index e9fecf4..cbf49bc 100644 --- a/README.md +++ b/README.md @@ -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 ` and `./scripts/stop.sh`, because its +scenarios are a running web application rather than a `main()` method. + ## License MIT — see [LICENSE](LICENSE). diff --git a/filter-chain/README.md b/filter-chain/README.md new file mode 100644 index 0000000..39f6cfa --- /dev/null +++ b/filter-chain/README.md @@ -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). diff --git a/filter-chain/docs/01-the-two-proxies.md b/filter-chain/docs/01-the-two-proxies.md new file mode 100644 index 0000000..f9c92de --- /dev/null +++ b/filter-chain/docs/01-the-two-proxies.md @@ -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`. 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) diff --git a/filter-chain/docs/02-the-default-chain.md b/filter-chain/docs/02-the-default-chain.md new file mode 100644 index 0000000..179a5bd --- /dev/null +++ b/filter-chain/docs/02-the-default-chain.md @@ -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 4–5) +3. The request is authenticated (filters 6–14) +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) diff --git a/filter-chain/docs/03-the-order-table.md b/filter-chain/docs/03-the-order-table.md new file mode 100644 index 0000000..1a94421 --- /dev/null +++ b/filter-chain/docs/03-the-order-table.md @@ -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) diff --git a/filter-chain/docs/04-where-custom-filters-land.md b/filter-chain/docs/04-where-custom-filters-land.md new file mode 100644 index 0000000..302560e --- /dev/null +++ b/filter-chain/docs/04-where-custom-filters-land.md @@ -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 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) diff --git a/filter-chain/docs/05-failure-modes.md b/filter-chain/docs/05-failure-modes.md new file mode 100644 index 0000000..8918f4e --- /dev/null +++ b/filter-chain/docs/05-failure-modes.md @@ -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) + - 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) diff --git a/filter-chain/docs/06-reading-the-trace.md b/filter-chain/docs/06-reading-the-trace.md new file mode 100644 index 0000000..ab97d73 --- /dev/null +++ b/filter-chain/docs/06-reading-the-trace.md @@ -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` 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) diff --git a/filter-chain/docs/07-multiple-chains.md b/filter-chain/docs/07-multiple-chains.md new file mode 100644 index 0000000..df9fdd9 --- /dev/null +++ b/filter-chain/docs/07-multiple-chains.md @@ -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) + - 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) diff --git a/filter-chain/docs/08-debugging-recipes.md b/filter-chain/docs/08-debugging-recipes.md new file mode 100644 index 0000000..b734efc --- /dev/null +++ b/filter-chain/docs/08-debugging-recipes.md @@ -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) diff --git a/filter-chain/docs/09-testing-the-chain.md b/filter-chain/docs/09-testing-the-chain.md new file mode 100644 index 0000000..9bbdac5 --- /dev/null +++ b/filter-chain/docs/09-testing-the-chain.md @@ -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) diff --git a/filter-chain/docs/README.md b/filter-chain/docs/README.md new file mode 100644 index 0000000..33c3161 --- /dev/null +++ b/filter-chain/docs/README.md @@ -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) diff --git a/filter-chain/docs/output/demo1-order-table.txt b/filter-chain/docs/output/demo1-order-table.txt new file mode 100644 index 0000000..f1f4982 --- /dev/null +++ b/filter-chain/docs/output/demo1-order-table.txt @@ -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 diff --git a/filter-chain/docs/output/demo10-multichain.txt b/filter-chain/docs/output/demo10-multichain.txt new file mode 100644 index 0000000..2f778de --- /dev/null +++ b/filter-chain/docs/output/demo10-multichain.txt @@ -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 diff --git a/filter-chain/docs/output/demo11-ignoring-vs-permitall.txt b/filter-chain/docs/output/demo11-ignoring-vs-permitall.txt new file mode 100644 index 0000000..52c64ee --- /dev/null +++ b/filter-chain/docs/output/demo11-ignoring-vs-permitall.txt @@ -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) + - 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 diff --git a/filter-chain/docs/output/demo12-servlet-filters.txt b/filter-chain/docs/output/demo12-servlet-filters.txt new file mode 100644 index 0000000..3b437bf --- /dev/null +++ b/filter-chain/docs/output/demo12-servlet-filters.txt @@ -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. diff --git a/filter-chain/docs/output/demo2-default-chain.txt b/filter-chain/docs/output/demo2-default-chain.txt new file mode 100644 index 0000000..e712a1e --- /dev/null +++ b/filter-chain/docs/output/demo2-default-chain.txt @@ -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) --- + diff --git a/filter-chain/docs/output/demo3-trace-authenticated.txt b/filter-chain/docs/output/demo3-trace-authenticated.txt new file mode 100644 index 0000000..4e23367 --- /dev/null +++ b/filter-chain/docs/output/demo3-trace-authenticated.txt @@ -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=]]] +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@ +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=]]] +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@]] +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=]] 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} diff --git a/filter-chain/docs/output/demo4-trace-csrf-403.txt b/filter-chain/docs/output/demo4-trace-csrf-403.txt new file mode 100644 index 0000000..f7a369f --- /dev/null +++ b/filter-chain/docs/output/demo4-trace-csrf-403.txt @@ -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@ +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@, matchingMediaTypes=[text/html], useEquals=false, ignoredMediaTypes=[]]], MediaTypeRequestMatcher [contentNegotiationStrategy=org.springframework.web.accept.ContentNegotiationManager@, 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@, matchingMediaTypes=[*/*], useEquals=true, ignoredMediaTypes=[]]] +DEBUG o.s.s.w.a.DelegatingAuthenticationEntryPoint : Match found! Executing org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint@ +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@ +DEBUG o.a.c.c.C.[.[.[/].[dispatcherServlet] : Disabling the response for further output diff --git a/filter-chain/docs/output/demo5-custom-placement.txt b/filter-chain/docs/output/demo5-custom-placement.txt new file mode 100644 index 0000000..9ce398b --- /dev/null +++ b/filter-chain/docs/output/demo5-custom-placement.txt @@ -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 diff --git a/filter-chain/docs/output/demo6-exception-translation.txt b/filter-chain/docs/output/demo6-exception-translation.txt new file mode 100644 index 0000000..5a0647e --- /dev/null +++ b/filter-chain/docs/output/demo6-exception-translation.txt @@ -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 diff --git a/filter-chain/docs/output/demo7-misordered.txt b/filter-chain/docs/output/demo7-misordered.txt new file mode 100644 index 0000000..d361323 --- /dev/null +++ b/filter-chain/docs/output/demo7-misordered.txt @@ -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 diff --git a/filter-chain/docs/output/demo8-tie.txt b/filter-chain/docs/output/demo8-tie.txt new file mode 100644 index 0000000..a2c1204 --- /dev/null +++ b/filter-chain/docs/output/demo8-tie.txt @@ -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. diff --git a/filter-chain/docs/output/demo9-double-registration.txt b/filter-chain/docs/output/demo9-double-registration.txt new file mode 100644 index 0000000..3c7d1e0 --- /dev/null +++ b/filter-chain/docs/output/demo9-double-registration.txt @@ -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) diff --git a/filter-chain/docs/output/tests.txt b/filter-chain/docs/output/tests.txt new file mode 100644 index 0000000..c4ba4ad --- /dev/null +++ b/filter-chain/docs/output/tests.txt @@ -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: +[INFO] ------------------------------------------------------------------------ diff --git a/filter-chain/pom.xml b/filter-chain/pom.xml new file mode 100644 index 0000000..dd0b13a --- /dev/null +++ b/filter-chain/pom.xml @@ -0,0 +1,67 @@ + + 4.0.0 + + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + filter-chain-verify + 1.0 + jar + + + 25 + UTF-8 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-security + + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + org.springframework.boot + spring-boot-starter-security-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/filter-chain/scripts/run-all.sh b/filter-chain/scripts/run-all.sh new file mode 100755 index 0000000..b5963fa --- /dev/null +++ b/filter-chain/scripts/run-all.sh @@ -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?//g' \ + -e 's/@[0-9a-f]{6,}/@/g' \ + -e 's/(JSESSIONID=)[0-9A-F]+/\1/g' \ + -e 's/remote=\/127\.0\.0\.1:[0-9]+/remote=\/127.0.0.1:/g' \ + -e '/Picked up JAVA_TOOL_OPTIONS/d' +} + +trace_request() { # trace_request + 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 ".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" diff --git a/filter-chain/scripts/run.sh b/filter-chain/scripts/run.sh new file mode 100755 index 0000000..7547731 --- /dev/null +++ b/filter-chain/scripts/run.sh @@ -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 diff --git a/filter-chain/scripts/stop.sh b/filter-chain/scripts/stop.sh new file mode 100755 index 0000000..4d033fd --- /dev/null +++ b/filter-chain/scripts/stop.sh @@ -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 diff --git a/filter-chain/src/main/java/com/ankurm/chain/FilterChainDemoApplication.java b/filter-chain/src/main/java/com/ankurm/chain/FilterChainDemoApplication.java new file mode 100644 index 0000000..89e1c9b --- /dev/null +++ b/filter-chain/src/main/java/com/ankurm/chain/FilterChainDemoApplication.java @@ -0,0 +1,28 @@ +package com.ankurm.chain; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Companion application for + * The Spring Security + * Filter Chain Explained. + * + *

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. + * + *

Start it with {@code ./scripts/run.sh } and regenerate every captured file + * under {@code docs/output/} with {@code ./scripts/run-all.sh}. + * + * @see com.ankurm.chain.web.DiagnosticsController + * @see Documentation index + */ +@SpringBootApplication +public class FilterChainDemoApplication { + + public static void main(String[] args) { + SpringApplication.run(FilterChainDemoApplication.class, args); + } + +} diff --git a/filter-chain/src/main/java/com/ankurm/chain/config/BaselineSecurityConfig.java b/filter-chain/src/main/java/com/ankurm/chain/config/BaselineSecurityConfig.java new file mode 100644 index 0000000..ac6c1c9 --- /dev/null +++ b/filter-chain/src/main/java/com/ankurm/chain/config/BaselineSecurityConfig.java @@ -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 — the one whose chain is reproduced in the article's table. + * Active by default ({@code spring.profiles.default=baseline}). + * + *

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 else in the resulting chain is there because + * {@code HttpSecurity} puts it there whether you ask or not, which is the interesting part. + * + *

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 docs/02-the-default-chain.md + */ +@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(); + } + +} diff --git a/filter-chain/src/main/java/com/ankurm/chain/config/CustomFiltersSecurityConfig.java b/filter-chain/src/main/java/com/ankurm/chain/config/CustomFiltersSecurityConfig.java new file mode 100644 index 0000000..93f6f5b --- /dev/null +++ b/filter-chain/src/main/java/com/ankurm/chain/config/CustomFiltersSecurityConfig.java @@ -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. + * + *

+ * addFilterAfter(RequestIdFilter,  SecurityContextHolderFilter)   ->  701
+ * addFilterAfter(ApiKeyAuthFilter, LogoutFilter)                  -> 1201
+ * addFilterAfter(TenantFilter[doc-placement], AnonymousAuthenticationFilter) -> 3701
+ * addFilterAfter(TenantFilter[after-translation], ExceptionTranslationFilter) -> 4001
+ * 
+ * + *

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 — see {@code docs/05-failure-modes.md}. + * + * @see docs/04-where-custom-filters-land.md + */ +@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(); + } + +} diff --git a/filter-chain/src/main/java/com/ankurm/chain/config/DoubleRegistrationConfig.java b/filter-chain/src/main/java/com/ankurm/chain/config/DoubleRegistrationConfig.java new file mode 100644 index 0000000..ff9ab37 --- /dev/null +++ b/filter-chain/src/main/java/com/ankurm/chain/config/DoubleRegistrationConfig.java @@ -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. + * + *

Spring Boot registers every {@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. + * + *

Under {@code doublereg} the counting filter is a plain {@code @Bean} and the response + * header reports 2. Under {@code doublereg,fixed} the same bean is wrapped in a + * disabled {@code FilterRegistrationBean}, which suppresses the container registration only, and + * the header reports 1. + * + * @see docs/05-failure-modes.md + */ +@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 disableContainerRegistration(CountingFilter filter) { + FilterRegistrationBean 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(); + } + +} diff --git a/filter-chain/src/main/java/com/ankurm/chain/config/IgnoringSecurityConfig.java b/filter-chain/src/main/java/com/ankurm/chain/config/IgnoringSecurityConfig.java new file mode 100644 index 0000000..7e145f7 --- /dev/null +++ b/filter-chain/src/main/java/com/ankurm/chain/config/IgnoringSecurityConfig.java @@ -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. + * + *

{@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. + * + *

{@code ignoring()} creates a {@code SecurityFilterChain} with zero filters. + * 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 docs/07-multiple-chains.md + */ +@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(); + } + +} diff --git a/filter-chain/src/main/java/com/ankurm/chain/config/MisorderedSecurityConfig.java b/filter-chain/src/main/java/com/ankurm/chain/config/MisorderedSecurityConfig.java new file mode 100644 index 0000000..1ee5b2e --- /dev/null +++ b/filter-chain/src/main/java/com/ankurm/chain/config/MisorderedSecurityConfig.java @@ -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 one + * slot to the wrong side of {@code AuthorizationFilter}. + * + *

{@code addFilterAfter(.., AuthorizationFilter.class)} gives it order 4201. The + * authorization decision has already been taken by then, against whatever principal was in the + * holder — 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. + * + *

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 docs/05-failure-modes.md + */ +@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(); + } + +} diff --git a/filter-chain/src/main/java/com/ankurm/chain/config/MultiChainSecurityConfig.java b/filter-chain/src/main/java/com/ankurm/chain/config/MultiChainSecurityConfig.java new file mode 100644 index 0000000..75ca0aa --- /dev/null +++ b/filter-chain/src/main/java/com/ankurm/chain/config/MultiChainSecurityConfig.java @@ -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. + * + *

{@code @Order} on the bean is what fixes evaluation order, and evaluation stops at the + * first match. Getting {@code @Order} wrong — or omitting it, which gives + * {@code Ordered.LOWEST_PRECEDENCE} and an unspecified relative order — 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 docs/07-multiple-chains.md + */ +@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(); + } + +} diff --git a/filter-chain/src/main/java/com/ankurm/chain/config/TieSecurityConfig.java b/filter-chain/src/main/java/com/ankurm/chain/config/TieSecurityConfig.java new file mode 100644 index 0000000..11c4e11 --- /dev/null +++ b/filter-chain/src/main/java/com/ankurm/chain/config/TieSecurityConfig.java @@ -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. + * + *

{@code addFilterBefore} computes {@code anchorOrder - 1}, not “a slot before whatever + * is already there”. Both of these land on 1099. The tie is broken by + * {@code List.sort}, which is stable, so the filters run in the order the {@code addFilterBefore} + * calls were made — a guarantee that comes from {@code java.util.List}, not from anything + * Spring Security documents. + * + *

The {@code TIE_REVERSED} system property flips the two calls, which flips the executed + * order. That diff is the proof. + * + * @see docs/04-where-custom-filters-land.md + */ +@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(); + } + +} diff --git a/filter-chain/src/main/java/com/ankurm/chain/config/UsersConfig.java b/filter-chain/src/main/java/com/ankurm/chain/config/UsersConfig.java new file mode 100644 index 0000000..3bfddbf --- /dev/null +++ b/filter-chain/src/main/java/com/ankurm/chain/config/UsersConfig.java @@ -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()); + } + +} diff --git a/filter-chain/src/main/java/com/ankurm/chain/filter/ApiKeyAuthenticationFilter.java b/filter-chain/src/main/java/com/ankurm/chain/filter/ApiKeyAuthenticationFilter.java new file mode 100644 index 0000000..d19e225 --- /dev/null +++ b/filter-chain/src/main/java/com/ankurm/chain/filter/ApiKeyAuthenticationFilter.java @@ -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. + * + *

Two details matter more than the parsing. + * + *

It must run before {@code AuthorizationFilter} (4200). That is the + * whole reason placement is a topic. The {@code misordered} profile adds this same filter + * after {@code AuthorizationFilter} and the result is a 401 with a perfectly valid + * key — the authorization decision was already taken against an anonymous principal. + * + *

It creates the context with {@code createEmptyContext()} rather than mutating the + * one already in the holder. 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 docs/04-where-custom-filters-land.md + * @see docs/05-failure-modes.md + */ +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); + } + +} diff --git a/filter-chain/src/main/java/com/ankurm/chain/filter/CountingFilter.java b/filter-chain/src/main/java/com/ankurm/chain/filter/CountingFilter.java new file mode 100644 index 0000000..22cc192 --- /dev/null +++ b/filter-chain/src/main/java/com/ankurm/chain/filter/CountingFilter.java @@ -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. + * + *

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 is still registered twice, still + * consumes a stack frame twice, and still runs twice in every dispatch that gets a fresh + * request — but the counter would read 1 and you would conclude there was no problem. + * + * @see docs/05-failure-modes.md + */ +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(); + } + +} diff --git a/filter-chain/src/main/java/com/ankurm/chain/filter/MarkerFilter.java b/filter-chain/src/main/java/com/ankurm/chain/filter/MarkerFilter.java new file mode 100644 index 0000000..7fb775d --- /dev/null +++ b/filter-chain/src/main/java/com/ankurm/chain/filter/MarkerFilter.java @@ -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 executed order of a set of + * filters can be read off the response instead of inferred from configuration. + * + *

Used by the {@code tie} profile, where two filters are added before the same anchor and + * therefore receive the same order number. + * + * @see docs/04-where-custom-filters-land.md + */ +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 markers = (List) 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 + "]"; + } + +} diff --git a/filter-chain/src/main/java/com/ankurm/chain/filter/MarkerFilterA.java b/filter-chain/src/main/java/com/ankurm/chain/filter/MarkerFilterA.java new file mode 100644 index 0000000..a04635c --- /dev/null +++ b/filter-chain/src/main/java/com/ankurm/chain/filter/MarkerFilterA.java @@ -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); + } + +} diff --git a/filter-chain/src/main/java/com/ankurm/chain/filter/MarkerFilterB.java b/filter-chain/src/main/java/com/ankurm/chain/filter/MarkerFilterB.java new file mode 100644 index 0000000..b9c3dd6 --- /dev/null +++ b/filter-chain/src/main/java/com/ankurm/chain/filter/MarkerFilterB.java @@ -0,0 +1,10 @@ +package com.ankurm.chain.filter; + +/** @see MarkerFilterA */ +public class MarkerFilterB extends MarkerFilter { + + public MarkerFilterB(String label) { + super(label); + } + +} diff --git a/filter-chain/src/main/java/com/ankurm/chain/filter/RequestIdFilter.java b/filter-chain/src/main/java/com/ankurm/chain/filter/RequestIdFilter.java new file mode 100644 index 0000000..cb19366 --- /dev/null +++ b/filter-chain/src/main/java/com/ankurm/chain/filter/RequestIdFilter.java @@ -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. + * + *

It is in this repository to make one point — a filter like this does not need to be + * in the security chain. Put it in the servlet chain and it wraps everything, 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. + * + *

Extending {@code OncePerRequestFilter} deliberately: see + * {@code docs/04-where-custom-filters-land.md} for why that class is not in the order + * table and what that means for {@code http.addFilter(..)}. + * + * @see docs/04-where-custom-filters-land.md + */ +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); + } + +} diff --git a/filter-chain/src/main/java/com/ankurm/chain/filter/TenantFilter.java b/filter-chain/src/main/java/com/ankurm/chain/filter/TenantFilter.java new file mode 100644 index 0000000..6816fc3 --- /dev/null +++ b/filter-chain/src/main/java/com/ankurm/chain/filter/TenantFilter.java @@ -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. + * + *

It signals refusal by throwing {@link AccessDeniedException}. Nothing in the servlet API + * turns that into a 403 — {@code ExceptionTranslationFilter} does, and it sits at order + * 4000. 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 + * 500 Internal Server Error. + * + *

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}. + * + *

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 class name. Two instances of + * the same {@code OncePerRequestFilter} subclass therefore share one attribute, and + * the second one silently never runs. Set {@code -DUNIQUE_ONCE_KEY=true} to + * give each instance its own key and watch the second filter come back to life. + * + * @see docs/05-failure-modes.md + */ +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 + "]"; + } + +} diff --git a/filter-chain/src/main/java/com/ankurm/chain/support/FilterOrderTable.java b/filter-chain/src/main/java/com/ankurm/chain/support/FilterOrderTable.java new file mode 100644 index 0000000..b71ee77 --- /dev/null +++ b/filter-chain/src/main/java/com/ankurm/chain/support/FilterOrderTable.java @@ -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} + * — the package-private class that decides the order of every filter in the chain. + * + *

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. + * + *

The map is keyed by class name string, not by {@code Class}, precisely because + * several entries name classes that may not be on the classpath — 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 + * anywhere: {@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 docs/03-the-order-table.md + */ +public final class FilterOrderTable { + + private static final String REGISTRATION_CLASS = + "org.springframework.security.config.annotation.web.builders.FilterOrderRegistration"; + + private static final Map TABLE = read(); + + private FilterOrderTable() { + } + + /** The whole registration table, keyed by fully-qualified class name. */ + public static Map table() { + return TABLE; + } + + /** + * The order Spring Security would give this filter type, resolved the same way + * {@code FilterOrderRegistration.getOrder(Class)} resolves it — 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 anchor) { + Integer anchorOrder = orderOf(anchor); + return (anchorOrder != null) ? anchorOrder - 1 : null; + } + + /** @see #orderIfAddedBefore(Class) */ + public static Integer orderIfAddedAfter(Class anchor) { + Integer anchorOrder = orderOf(anchor); + return (anchorOrder != null) ? anchorOrder + 1 : null; + } + + @SuppressWarnings("unchecked") + private static Map 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) 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); + } + } + +} diff --git a/filter-chain/src/main/java/com/ankurm/chain/web/DemoController.java b/filter-chain/src/main/java/com/ankurm/chain/web/DemoController.java new file mode 100644 index 0000000..7f935bd --- /dev/null +++ b/filter-chain/src/main/java/com/ankurm/chain/web/DemoController.java @@ -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 markers = (List) request.getAttribute(MarkerFilter.ATTRIBUTE); + return "executed order: " + markers + "\n"; + } + +} diff --git a/filter-chain/src/main/java/com/ankurm/chain/web/DiagnosticsController.java b/filter-chain/src/main/java/com/ankurm/chain/web/DiagnosticsController.java new file mode 100644 index 0000000..0afc2e6 --- /dev/null +++ b/filter-chain/src/main/java/com/ankurm/chain/web/DiagnosticsController.java @@ -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. + * + *

Three views, and they answer three different questions: + * + *

    + *
  • {@code /diag/chains} — what {@link FilterChainProxy} actually holds. This is the + * only authoritative answer to “which filters run for my request”, because it + * is read back out of the live bean rather than inferred from configuration.
  • + *
  • {@code /diag/order} — 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.
  • + *
  • {@code /diag/servlet-filters} — what the servlet container has registered. A + * custom security filter that appears here as well as in {@code /diag/chains} + * runs twice per request.
  • + *
+ * + *

Delete this controller before shipping. It publishes your entire security + * topology to anyone who can reach it. + * + * @see docs/02-the-default-chain.md + * @see docs/04-where-custom-filters-land.md + */ +@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. + * + *

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 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 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(" - 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 — 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 table = FilterOrderTable.table(); + List> entries = new ArrayList<>(table.entrySet()); + entries.sort(Comparator.comparing(Map.Entry::getValue)); + int expected = 100; + for (Map.Entry 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 + * (“springSecurityFilterChain”, 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 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(); + } + +} diff --git a/filter-chain/src/main/resources/application.properties b/filter-chain/src/main/resources/application.properties new file mode 100644 index 0000000..7cb10dc --- /dev/null +++ b/filter-chain/src/main/resources/application.properties @@ -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 diff --git a/filter-chain/src/test/java/com/ankurm/chain/FilterChainContractTest.java b/filter-chain/src/test/java/com/ankurm/chain/FilterChainContractTest.java new file mode 100644 index 0000000..be831b4 --- /dev/null +++ b/filter-chain/src/test/java/com/ankurm/chain/FilterChainContractTest.java @@ -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. + * + *

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 docs/09-testing-the-chain.md + */ +class FilterChainContractTest { + + private static List 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 filters = this.proxy.getFilterChains().get(0).getFilters(); + List 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 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 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 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 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"); + } + + } + +} diff --git a/filter-chain/src/test/java/com/ankurm/chain/FilterOrderTableTest.java b/filter-chain/src/test/java/com/ankurm/chain/FilterOrderTableTest.java new file mode 100644 index 0000000..da296e0 --- /dev/null +++ b/filter-chain/src/test/java/com/ankurm/chain/FilterOrderTableTest.java @@ -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 docs/03-the-order-table.md + */ +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 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(); + } + +} diff --git a/method-security/docs/01-how-method-security-runs.md b/method-security/docs/01-how-method-security-runs.md index 36836b5..e94bd2f 100644 --- a/method-security/docs/01-how-method-security-runs.md +++ b/method-security/docs/01-how-method-security-runs.md @@ -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)