From bbf2a0a1ac98a32415bdaff1e26b2ebbb2cf98aa Mon Sep 17 00:00:00 2001 From: null Date: Mon, 17 Aug 2026 23:30:37 -0500 Subject: [PATCH] feat(security): preflight.sh distinguishes present from in force Run against its first real target the script reported "ok strict-transport-security present". The response carried two of them: strict-transport-security: max-age=63072000; includeSubDomains strict-transport-security: max-age=63072000; preload RFC 6797 section 8.1 -- more than one and the agent MUST process only the first -- so what was in force was includeSubDomains without preload, and preload had never once applied while the headers read, to a person, as though the site were preload-ready. Two layers each adding their own is all it takes, and the second is discarded in silence. Each security header is now counted, and more than one is a finding naming the directives that actually survive. Two details that each took a wrong answer to get right, both the same class of error the check exists to catch -- a tool answering confidently and wrongly: - The value comes from the FIRST occurrence of the FINAL response block. Using the last named the second header as the one in force, which is precisely backwards, and curl -L concatenates every hop so an unscoped search quotes a redirect's copy rather than the page's. - It is quoted from the original headers rather than the lowercased copy used for matching. Reporting `includesubdomains` to somebody who wrote `includeSubDomains` shows them a value they never sent. Verified against a local server serving each shape, and against the origin that prompted it, where it now names 'max-age=63072000; includeSubDomains' as in force -- matching the wire byte for byte. closes #12 --- docs/architecture/scripts/preflight.sh | 50 ++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) 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