1
0

Add the cors-csrf module

This commit is contained in:
2026-08-28 09:33:22 +05:30
parent 73ab67b171
commit cad813e1ae
49 changed files with 3338 additions and 13 deletions

25
cors-csrf/scripts/preflight.sh Executable file
View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Send one CORS preflight and print the status line and the headers that decide the outcome.
#
# ./scripts/preflight.sh https://spa.example.com POST /api/data
#
# A preflight is not a special kind of request. It is an OPTIONS carrying Origin and
# Access-Control-Request-Method, and it is sent WITHOUT cookies or an Authorization header -
# which is precisely why a chain that requires authentication rejects it.
set -eu
ORIGIN="${1:-https://spa.example.com}"
METHOD="${2:-POST}"
PATH_="${3:-/api/data}"
echo "\$ curl -s -i -X OPTIONS http://localhost:8080$PATH_ \\"
echo " -H 'Origin: $ORIGIN' \\"
echo " -H 'Access-Control-Request-Method: $METHOD' \\"
echo " -H 'Access-Control-Request-Headers: content-type,x-xsrf-token'"
echo
curl -s -i -X OPTIONS "http://localhost:8080$PATH_" \
-H "Origin: $ORIGIN" \
-H "Access-Control-Request-Method: $METHOD" \
-H "Access-Control-Request-Headers: content-type,x-xsrf-token" \
| sed -n '1,/^\r$/p' \
| grep -viE '^(date|keep-alive|connection|content-length|transfer-encoding):' \
| sed 's/\r$//'

355
cors-csrf/scripts/run-all.sh Executable file
View File

@@ -0,0 +1,355 @@
#!/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 restarts 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 committed files diff cleanly.
scrub() {
sed -E \
-e 's/\r$//' \
-e 's/[0-9]{4}-[0-9]{2}-[0-9]{2}[T ][0-9:.]+(Z|\+[0-9:]+)?/<timestamp>/g' \
-e 's/(JSESSIONID=)[0-9A-F]+/\1<session>/g' \
-e 's/(XSRF-TOKEN=|MY-CSRF=)[0-9a-f-]{36}/\1<token>/g' \
-e 's/(X-XSRF-TOKEN: |X-CSRF-TOKEN: )[0-9a-f-]{36}/\1<token>/g' \
-e '/^(Date|Keep-Alive|Connection|Content-Length|Transfer-Encoding|Expires):/d' \
-e 's/PID [0-9]+/PID <pid>/g' \
-e 's/in [0-9.]+ seconds \(process running for [0-9.]+\)/in <n> seconds/g' \
-e 's/ [0-9]+ --- / <pid> --- /g' \
-e 's/\[nio-8080-exec-[0-9]+\]/[nio-8080-exec-N]/g' \
-e '/Picked up JAVA_TOOL_OPTIONS/d' \
| cat -s
}
headers() { # headers <curl args...>
curl -s -i "$@" | sed -n '1,/^\r$/p' | grep -viE '^(date|keep-alive|connection|content-length|transfer-encoding|content-type|content-language):'
}
logs_since() { # logs_since <marker-line-count> <grep-pattern>
sed -n "$(( $1 + 1 )),\$p" /tmp/cors-csrf-app.log | grep -E "$2" || true
}
########################################################################################
# 1. CORS on the MVC layer only - the preflight never reaches the servlet
########################################################################################
./scripts/run.sh mvconly > /dev/null
{
hdr "docs/output/01-mvc-only.txt
CORS configured with WebMvcConfigurer.addCorsMappings and nothing else.
Profile: mvconly"
echo "# The security chain. Note what is NOT in it."
echo "\$ curl -s localhost:8080/diag/chain"
curl -s localhost:8080/diag/chain | python3 -m json.tool
echo
echo "# CorsConfigurationSource beans in the context."
echo "\$ curl -s localhost:8080/diag/cors-sources"
curl -s localhost:8080/diag/cors-sources | python3 -m json.tool
echo
./scripts/preflight.sh
echo
echo "# The MVC CORS mapping is real - it just never runs, because the request is"
echo "# rejected at AuthorizationFilter (order 4200) and the DispatcherServlet is"
echo "# downstream of the entire filter chain."
} | scrub > "$OUT/01-mvc-only.txt"
########################################################################################
# 2. The same MVC configuration, with .cors(withDefaults()) added
########################################################################################
./scripts/run.sh mvcbridge > /dev/null
{
hdr "docs/output/02-mvc-bridge.txt
The identical MVC CORS mapping plus one line: .cors(Customizer.withDefaults()).
Profile: mvcbridge"
echo "\$ curl -s localhost:8080/diag/chain"
curl -s localhost:8080/diag/chain | python3 -m json.tool
echo
./scripts/preflight.sh
echo
echo "# CorsFilter is now in the chain at order 1000, between HeaderWriterFilter (900)"
echo "# and CsrfFilter (1100), and it short-circuits the preflight before authorization"
echo "# ever sees it. Note Access-Control-Max-Age: 1800 - that default comes from MVC's"
echo "# CorsRegistration, not from CorsConfiguration."
} | scrub > "$OUT/02-mvc-bridge.txt"
########################################################################################
# 3. A CorsConfigurationSource bean, correctly named
########################################################################################
./scripts/run.sh securitysource > /dev/null
{
hdr "docs/output/03-security-source.txt
A @Bean named corsConfigurationSource. .cors(..) is never called - it is applied for us.
Profile: securitysource"
echo "\$ curl -s localhost:8080/diag/cors-sources"
curl -s localhost:8080/diag/cors-sources | python3 -m json.tool
echo
./scripts/preflight.sh
echo
echo "# Compare with 02: there is no Access-Control-Max-Age here. CorsConfiguration"
echo "# leaves maxAge null, so every single cross-origin call re-runs the preflight."
} | scrub > "$OUT/03-security-source.txt"
########################################################################################
# 4. Three rejections that look identical from the client
########################################################################################
{
hdr "docs/output/04-three-identical-403s.txt
Origin not allowed, method not allowed, header not allowed. One status, one shape.
Profile: securitysource, CORS_LOG_LEVEL=DEBUG"
} > "$OUT/04-three-identical-403s.txt"
CORS_LOG_LEVEL=DEBUG ./scripts/run.sh securitysource > /dev/null
MARK=$(wc -l < /tmp/cors-csrf-app.log)
{
echo "# 1. disallowed origin"
headers -X OPTIONS localhost:8080/api/data -H 'Origin: https://evil.example.com' -H 'Access-Control-Request-Method: POST'
echo "# 2. disallowed method"
headers -X OPTIONS localhost:8080/api/data -H 'Origin: https://spa.example.com' -H 'Access-Control-Request-Method: DELETE'
echo "# 3. disallowed request header"
headers -X OPTIONS localhost:8080/api/data -H 'Origin: https://spa.example.com' -H 'Access-Control-Request-Method: POST' -H 'Access-Control-Request-Headers: authorization'
echo "# body of a rejected preflight:"
curl -s -X OPTIONS localhost:8080/api/data -H 'Origin: https://evil.example.com' -H 'Access-Control-Request-Method: POST'
echo
echo
echo "# The only thing that distinguishes them is a DEBUG line from DefaultCorsProcessor:"
sleep 1
logs_since "$MARK" 'DefaultCorsProcessor'
} | scrub >> "$OUT/04-three-identical-403s.txt"
########################################################################################
# 5. The bean-name trap: right type, wrong name
########################################################################################
CORS_LOG_LEVEL=DEBUG ./scripts/run.sh misnamed > /dev/null
MARK=$(wc -l < /tmp/cors-csrf-app.log)
{
hdr "docs/output/05-misnamed-bean.txt
The same UrlBasedCorsConfigurationSource bean, named apiCorsSource instead of
corsConfigurationSource. It starts. The preflight returns 200. It carries no CORS headers.
Profile: misnamed"
echo "\$ curl -s localhost:8080/diag/cors-sources"
curl -s localhost:8080/diag/cors-sources | python3 -m json.tool
echo
./scripts/preflight.sh
echo
sleep 1
logs_since "$MARK" 'DefaultCorsProcessor'
echo
echo "# Two different lookups. HttpSecurityConfiguration.applyCorsIfAvailable asks"
echo "# getBeanNamesForType(UrlBasedCorsConfigurationSource.class) and enables CORS if the"
echo "# array is non-empty, so the bean above DID switch the configurer on."
echo "# CorsConfigurer.getCorsConfigurationSource then asks"
echo "# containsBeanDefinition(\"corsConfigurationSource\"), which is false, and falls back"
echo "# to Spring MVC's registrations - of which there are none."
echo "# CorsFilter returns from every preflight whether or not it found a configuration:"
echo "# if (!isValid || CorsUtils.isPreFlightRequest(request)) { return; }"
echo "# so the OPTIONS never reaches AuthorizationFilter and the client gets a bare 200."
} | scrub > "$OUT/05-misnamed-bean.txt"
########################################################################################
# 6. Two sources - the documentation says CORS is not configured. It is.
########################################################################################
CORS_LOG_LEVEL=DEBUG ./scripts/run.sh twosources > /dev/null
MARK=$(wc -l < /tmp/cors-csrf-app.log)
{
hdr "docs/output/06-two-sources.txt
Two UrlBasedCorsConfigurationSource beans. The reference documentation says Spring Security
'won't automatically configure CORS support for you, because it cannot decide which one to
use'. In 7.1.1 it configures it, and the bean NAME decides.
Profile: twosources"
curl -s localhost:8080/diag/cors-sources | python3 -m json.tool
echo
echo "# the origin allowed by the bean named corsConfigurationSource:"
headers -X OPTIONS localhost:8080/api/data -H 'Origin: https://spa.example.com' -H 'Access-Control-Request-Method: POST' -H 'Access-Control-Request-Headers: content-type'
echo "# the origin allowed by adminCorsSource, which is never consulted:"
headers -X OPTIONS localhost:8080/api/data -H 'Origin: https://admin.example.com' -H 'Access-Control-Request-Method: POST' -H 'Access-Control-Request-Headers: content-type'
sleep 1
logs_since "$MARK" 'DefaultCorsProcessor'
} | scrub > "$OUT/06-two-sources.txt"
########################################################################################
# 7. allowedOrigins("*") with allowCredentials(true)
########################################################################################
./scripts/run.sh wildcard > /dev/null
MARK=$(wc -l < /tmp/cors-csrf-app.log)
{
hdr "docs/output/07-wildcard-credentials.txt
allowedOrigins(\"*\") together with allowCredentials(true). Legal to configure, illegal to
serve. The failure is thrown on the request, not at startup - and it does not surface as a 500.
Profile: wildcard"
headers -X OPTIONS localhost:8080/api/data -H 'Origin: https://spa.example.com' -H 'Access-Control-Request-Method: POST'
echo "# and a plain authenticated GET, with correct credentials:"
headers -u alice:password localhost:8080/api/data -H 'Origin: https://spa.example.com'
echo
sleep 1
logs_since "$MARK" 'IllegalArgumentException: When allowCredentials|at org.springframework.web.cors' | head -5
echo
echo "# 401, not 500. The exception escapes CorsFilter, Tomcat re-dispatches to /error,"
echo "# the security chain runs again on that dispatch without re-reading the credential,"
echo "# and the anonymous second pass is what answers."
} | scrub > "$OUT/07-wildcard-credentials.txt"
########################################################################################
# 8. CSRF for a SPA: the pre-6.0 recipe
########################################################################################
CSRF_LOG_LEVEL=DEBUG ./scripts/run.sh csrfnaive > /dev/null
MARK=$(wc -l < /tmp/cors-csrf-app.log)
J=$(mktemp); rm -f "$J"
{
hdr "docs/output/08-csrf-naive.txt
CookieCsrfTokenRepository.withHttpOnlyFalse() on its own - the recipe from every pre-6.0
tutorial. Three separate things go wrong.
Profile: csrfnaive"
echo "# 1. The bootstrap GET. A SPA expects an XSRF-TOKEN cookie here."
headers -c "$J" -u alice:password localhost:8080/api/data
echo "# cookie jar after the GET:"
{ grep -v '^#' "$J" | sed 's/\t/ | /g' | grep . || echo "(empty - no cookie was set)"; }
echo
echo "# 2. POST with no token."
headers -b "$J" -c "$J" -u alice:password -X POST localhost:8080/api/data -H 'Content-Type: application/json' -d '{}'
echo "# cookie jar now:"
grep -v '^#' "$J" | sed 's/\t/ | /g'
echo
echo "# 3. POST echoing the raw cookie value back in X-XSRF-TOKEN, which is what every"
echo "# SPA snippet on the internet does."
TOK=$(grep XSRF-TOKEN "$J" | awk '{print $NF}')
headers -b "$J" -u alice:password -X POST localhost:8080/api/data -H "X-XSRF-TOKEN: $TOK" -H 'Content-Type: application/json' -d '{}'
echo
sleep 1
logs_since "$MARK" 'CsrfFilter'
} | scrub > "$OUT/08-csrf-naive.txt"
########################################################################################
# 9. The same failure with /error permitted - the status the SPA never sees
########################################################################################
CSRF_LOG_LEVEL=DEBUG ./scripts/run.sh csrfnaive,errorpermit > /dev/null
{
hdr "docs/output/09-error-dispatch.txt
The identical CSRF failure, with one extra filter chain that permits /error.
Profile: csrfnaive,errorpermit"
headers -u alice:password -X POST localhost:8080/api/data -H 'Content-Type: application/json' -d '{}'
curl -s -u alice:password -X POST localhost:8080/api/data -H 'Content-Type: application/json' -d '{}'
echo
echo
echo "# 403, and a body. Without the /error chain the same request answers 401 with an"
echo "# empty body and a WWW-Authenticate header - see 08. AccessDeniedHandlerImpl calls"
echo "# response.sendError(403), the container re-dispatches to /error, and the security"
echo "# chain runs a second time on that dispatch. BasicAuthenticationFilter extends"
echo "# OncePerRequestFilter and skips error dispatches, so the second pass is anonymous"
echo "# and AuthorizationFilter answers 401 over the top of the 403."
} | scrub > "$OUT/09-error-dispatch.txt"
########################################################################################
# 10. csrf.spa()
########################################################################################
./scripts/run.sh csrfspa > /dev/null
J=$(mktemp); rm -f "$J"
{
hdr "docs/output/10-csrf-spa.txt
The same flow under csrf.spa(), added in Spring Security 7.0.
Profile: csrfspa"
echo "# 1. The bootstrap GET now DOES set the cookie."
headers -c "$J" -u alice:password localhost:8080/api/data
echo
echo "# 2. POST with no token still fails, as it must."
headers -b "$J" -u alice:password -X POST localhost:8080/api/data -H 'Content-Type: application/json' -d '{}'
echo
echo "# 3. POST echoing the raw cookie value in X-XSRF-TOKEN."
TOK=$(grep XSRF-TOKEN "$J" | awk '{print $NF}')
headers -b "$J" -u alice:password -X POST localhost:8080/api/data -H "X-XSRF-TOKEN: $TOK" -H 'Content-Type: application/json' -d '{}'
curl -s -b "$J" -u alice:password -X POST localhost:8080/api/data -H "X-XSRF-TOKEN: $TOK" -H 'Content-Type: application/json' -d '{}'
echo
echo
echo "# Note the cookie attributes: Path=/ and nothing else. No SameSite, no Secure,"
echo "# no HttpOnly. A cookie with no SameSite attribute is treated as Lax, so a"
echo "# genuinely cross-site SPA still never receives it. See 12."
} | scrub > "$OUT/10-csrf-spa.txt"
########################################################################################
# 11. spa() discards a repository configured before it
########################################################################################
./scripts/run.sh spaorder > /dev/null
J=$(mktemp); rm -f "$J"
{
hdr "docs/output/11-spa-ordering.txt
.csrf(c -> c.csrfTokenRepository(custom).spa()) - the custom repository asked for a cookie
named MY-CSRF and a header named X-CSRF-TOKEN. Neither reaches the running application.
Profile: spaorder"
headers -c "$J" -u alice:password localhost:8080/api/data
echo "# cookie jar:"
grep -v '^#' "$J" | sed 's/\t/ | /g'
echo
echo "# spa() assigns csrfTokenRepository and requestHandler unconditionally; it is not a"
echo "# 'defaults if unset' method. Swap the two calls and MY-CSRF appears."
} | scrub > "$OUT/11-spa-ordering.txt"
########################################################################################
# 12. SameSite - what is actually written, and what a browser does with it
########################################################################################
{
hdr "docs/output/12-samesite.txt
The Set-Cookie headers this application emits under four configurations, and what
SpecCookieJar - a model of RFC 6265bis 5.5 and 5.8.3 - does with them."
} > "$OUT/12-samesite.txt"
emit() { # emit <label> <env...>
local label="$1"; shift
env "$@" ./scripts/run.sh "$PROFILE" > /dev/null
echo "## $label"
curl -s -D- -o /dev/null -u alice:password localhost:8080/api/data | grep -i '^set-cookie' | sed 's/\r$//'
curl -s -D- -o /dev/null localhost:8080/api/data | grep -i '^set-cookie' | grep -i jsessionid | sed 's/\r$//' || true
echo
}
{
PROFILE=csrfspa
emit "csrf.spa() defaults, session cookie left at same-site=lax" SESSION_SAME_SITE=lax SESSION_SECURE=false
emit "session cookie set to same-site=none, secure=false" SESSION_SAME_SITE=none SESSION_SECURE=false
PROFILE=crosssite
emit "crosssite profile: SameSite=None and Secure on both cookies" SESSION_SAME_SITE=none SESSION_SECURE=true
emit "crosssite profile with -DOMIT_SECURE=true" JVM_ARGS=-DOMIT_SECURE=true SESSION_SAME_SITE=none SESSION_SECURE=false
} | scrub >> "$OUT/12-samesite.txt"
./scripts/run.sh csrfspa > /dev/null
{
echo "## The same headers, run through SpecCookieJar"
python3 - <<'PY'
import urllib.parse, urllib.request, json
headers = [
"JSESSIONID=s1; Path=/; HttpOnly; SameSite=Lax",
"JSESSIONID=s2; Path=/; HttpOnly; SameSite=None",
"JSESSIONID=s3; Path=/; Secure; HttpOnly; SameSite=None",
"XSRF-TOKEN=t1; Path=/",
"XSRF-TOKEN=t2; Path=/; SameSite=None",
"XSRF-TOKEN=t3; Path=/; Secure; SameSite=None",
]
query = "&".join("h=" + urllib.parse.quote(h) for h in headers)
for secure in ("false", "true"):
url = f"http://localhost:8080/diag/cookie-spec?{query}&secure={secure}"
print(json.dumps(json.load(urllib.request.urlopen(url)), indent=2))
print()
PY
echo "# Read the second block first: over a trustworthy origin, the only two of the six"
echo "# that reach a cross-site fetch are the two carrying Secure AND SameSite=None."
echo "# Then read the first: over plain http, none do -"
echo "# which is why a cross-site SPA cannot be developed against http://127.0.0.1."
echo "# (http://localhost itself is treated as trustworthy by current browsers; a bare IP"
echo "# is not.)"
} | scrub >> "$OUT/12-samesite.txt"
########################################################################################
# 13. The assertions
########################################################################################
{
hdr "docs/output/13-tests.txt
mvn -B test"
(cd . && mvn -B test 2>&1) | grep -E 'Tests run|ERROR|BUILD|CorsCsrf' | head -30
} | scrub > "$OUT/13-tests.txt"
./scripts/stop.sh
echo "regenerated $(ls "$OUT" | wc -l) files under $OUT"

37
cors-csrf/scripts/run.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Start the demo application under a given profile and wait until it answers.
#
# ./scripts/run.sh securitysource
# ./scripts/run.sh mvconly
# SESSION_SAME_SITE=none SESSION_SECURE=false ./scripts/run.sh crosssite
# JVM_ARGS=-DOMIT_SECURE=true ./scripts/run.sh crosssite
# CSRF_LOG_LEVEL=DEBUG ./scripts/run.sh csrfnaive
#
# Two profiles are expected to FAIL to start - `misnamed` and `preflightclash`. That is what
# they demonstrate, so this script returns 1 for them and the transcript keeps the exception.
set -eu
cd "$(dirname "$0")/.."
PROFILE="${1:-securitysource}"
LOG="${LOG:-/tmp/cors-csrf-app.log}"
./scripts/stop.sh
setsid nohup mvn -B org.springframework.boot:spring-boot-maven-plugin:run \
-Dspring-boot.run.profiles="$PROFILE" \
-Dspring-boot.run.jvmArguments="${JVM_ARGS:-}" \
> "$LOG" 2>&1 < /dev/null &
for _ in $(seq 1 90); do
if curl -sf -o /dev/null http://localhost:8080/diag/chain 2>/dev/null; then
echo "started with profile: $PROFILE (log: $LOG)"
exit 0
fi
if grep -q 'APPLICATION FAILED TO START' "$LOG" 2>/dev/null; then
echo "application failed to start under profile: $PROFILE (log: $LOG)" >&2
exit 1
fi
sleep 2
done
echo "application did not become ready; see $LOG" >&2
exit 1

11
cors-csrf/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. Match the MAIN CLASS, never 'spring-boot' - that pattern also matches the shell
# command line that started the application, so pkill -f 'spring-boot' kills your own shell.
set -eu
for pid in $(ps -eo pid,cmd | grep '[C]orsCsrfApplication' | awk '{print $1}'); do
kill -9 "$pid" 2>/dev/null || true
done
sleep 1