1
0

Add the filter-chain module

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

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

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

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

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

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

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

11
filter-chain/scripts/stop.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Stop the demo application.
#
# Note the bracket in the grep pattern: it stops the pattern matching this script's own
# process. And note that we match the MAIN CLASS, not 'spring-boot' - matching 'spring-boot'
# also matches the shell command line that started it, which kills your own shell.
set -eu
for pid in $(ps -eo pid,cmd | grep '[F]ilterChainDemoApplication' | awk '{print $1}'); do
kill -9 "$pid" 2>/dev/null || true
done
sleep 1