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
This commit is contained in:
null 2026-08-17 23:30:37 -05:00
parent 9667b46585
commit bbf2a0a1ac
1 changed files with 47 additions and 3 deletions

View File

@ -49,6 +49,14 @@
# The active checks are further gated behind --auth, so the default run sends # The active checks are further gated behind --auth, so the default run sends
# exactly two GETs and could not be mistaken for anything. # 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 # ## What it cannot tell you
# #
# A header being present is not a header being correct — a CSP of # 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:]') 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() { # <header-name>
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 case "$TARGET" in
https://*) ok "the target is https" ;; https://*) ok "the target is https" ;;
*) finding "the target is not https — everything in transit is readable, including the session cookie" ;; *) finding "the target is not https — everything in transit is readable, including the session cookie" ;;
esac esac
if printf '%s' "$lower_headers" | grep -q '^content-security-policy:'; then 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 else
finding "no content-security-policy header — injected script has nothing to stop it" finding "no content-security-policy header — injected script has nothing to stop it"
fi fi
@ -165,13 +209,13 @@ fi
# and x-frame-options the one older browsers read, so one of the two is enough. # 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:' \ if printf '%s' "$lower_headers" | grep -q '^x-frame-options:' \
|| printf '%s' "$lower_headers" | grep -q 'frame-ancestors'; then || printf '%s' "$lower_headers" | grep -q 'frame-ancestors'; then
ok "framing policy present" check_duplicate x-frame-options && ok "framing policy present"
else else
finding "neither x-frame-options nor a csp frame-ancestors — the page can be framed and clickjacked" finding "neither x-frame-options nor a csp frame-ancestors — the page can be framed and clickjacked"
fi fi
if printf '%s' "$lower_headers" | grep -q '^strict-transport-security:'; then 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 else
finding "no strict-transport-security — the first request of each visit can still be plaintext" finding "no strict-transport-security — the first request of each visit can still be plaintext"
fi fi