#!/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:]+)?//g' \ -e 's/(JSESSIONID=)[0-9A-F]+/\1/g' \ -e 's/(XSRF-TOKEN=|MY-CSRF=)[0-9a-f-]{36}/\1/g' \ -e 's/(X-XSRF-TOKEN: |X-CSRF-TOKEN: )[0-9a-f-]{36}/\1/g' \ -e '/^(Date|Keep-Alive|Connection|Content-Length|Transfer-Encoding|Expires):/d' \ -e 's/PID [0-9]+/PID /g' \ -e 's/in [0-9.]+ seconds \(process running for [0-9.]+\)/in seconds/g' \ -e 's/ [0-9]+ --- / --- /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 -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 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