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:
230
filter-chain/scripts/run-all.sh
Executable file
230
filter-chain/scripts/run-all.sh
Executable 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
38
filter-chain/scripts/run.sh
Executable 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
11
filter-chain/scripts/stop.sh
Executable file
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stop the demo application.
|
||||
#
|
||||
# Note the bracket in the grep pattern: it stops the pattern matching this script's own
|
||||
# process. 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
|
||||
Reference in New Issue
Block a user