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:
parent
9667b46585
commit
bbf2a0a1ac
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue