diff --git a/docs/architecture/scripts/preflight.sh b/docs/architecture/scripts/preflight.sh index c99787c..c1f6343 100755 --- a/docs/architecture/scripts/preflight.sh +++ b/docs/architecture/scripts/preflight.sh @@ -49,6 +49,14 @@ # The active checks are further gated behind --auth, so the default run sends # exactly two GETs and could not be mistaken for anything. # +# ## Present is not in force +# +# It also counts each header rather than only looking for one. More than one +# copy means the later ones are discarded — RFC 6797 requires exactly that for +# HSTS and browsers do the same elsewhere — so a directive can sit in a response, +# be read by a person as applying, and have never once applied. Two layers each +# adding their own is all it takes. +# # ## What it cannot tell you # # A header being present is not a header being correct — a CSP of @@ -150,13 +158,49 @@ HEADERS=$(curl -sS -I -L --max-time 20 "$TARGET" 2>/dev/null) \ lower_headers=$(printf '%s' "$HEADERS" | tr '[:upper:]' '[:lower:]') +# `curl -L` concatenates the headers of every response in the chain, so a header +# counted across all of them would report a redirect's copy as a duplicate of +# the final page's. Only the last response block is counted. +final_block() { awk 'tolower($0) ~ /^http\// { buf = "" } { buf = buf $0 "\n" } END { printf "%s", buf }'; } +final_headers=$(printf '%s' "$lower_headers" | final_block) +final_raw=$(printf '%s' "$HEADERS" | tr -d '\r' | final_block) + +# Present is not the same as in force. RFC 6797 section 8.1 is explicit for HSTS +# -- more than one and the agent MUST process only the first -- and browsers +# behave the same way for the others: the second copy is discarded in silence. +# Two layers each adding their own is the ordinary cause, and the result reads +# to a person as though both applied. +# +# Found on this script's first real target: an origin serving +# strict-transport-security: max-age=63072000; includeSubDomains +# strict-transport-security: max-age=63072000; preload +# where preload had never once been in force. +check_duplicate() { # + local name="$1" count first + count=$(printf '%s' "$final_headers" | grep -cE "^${name}:" || true) + [ "${count:-0}" -gt 1 ] || return 0 + # Counted against the lowercased copy, but quoted from the original: echoing + # `includesubdomains` back at somebody who wrote `includeSubDomains` reports a + # value they did not send. Directive names are case-insensitive; the report + # should still show what is actually on the wire. + # The FIRST occurrence, within the FINAL response block. Both halves matter: + # RFC 6797 processes the first and discards the rest, and `curl -L` hands us + # the headers of every hop, so searching the whole buffer would quote a + # redirect's copy. Quoted from the original rather than the lowercased copy — + # echoing `includesubdomains` at somebody who wrote `includeSubDomains` + # reports a value they never sent. + first=$(printf '%s' "$final_raw" | grep -iE "^${name}:" | head -n 1 | sed -E "s/^[^:]*:[[:space:]]*//") + finding "${count} ${name} headers — only the first is processed, so what is in force is '${first}' and every later copy is discarded silently" + return 1 +} + case "$TARGET" in https://*) ok "the target is https" ;; *) finding "the target is not https — everything in transit is readable, including the session cookie" ;; esac if printf '%s' "$lower_headers" | grep -q '^content-security-policy:'; then - ok "content-security-policy present" + check_duplicate content-security-policy && ok "content-security-policy present" else finding "no content-security-policy header — injected script has nothing to stop it" fi @@ -165,13 +209,13 @@ fi # and x-frame-options the one older browsers read, so one of the two is enough. if printf '%s' "$lower_headers" | grep -q '^x-frame-options:' \ || printf '%s' "$lower_headers" | grep -q 'frame-ancestors'; then - ok "framing policy present" + check_duplicate x-frame-options && ok "framing policy present" else finding "neither x-frame-options nor a csp frame-ancestors — the page can be framed and clickjacked" fi if printf '%s' "$lower_headers" | grep -q '^strict-transport-security:'; then - ok "strict-transport-security present" + check_duplicate strict-transport-security && ok "strict-transport-security present" else finding "no strict-transport-security — the first request of each visit can still be plaintext" fi