356 lines
18 KiB
Bash
Executable File
356 lines
18 KiB
Bash
Executable File
#!/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"
|