#!/usr/bin/env bash # # The checks you can run against a live URL in two minutes, before strangers do. # # bash scripts/preflight.sh # passive: headers and TLS # bash scripts/preflight.sh --auth # also the two that generate load # bash scripts/preflight.sh --dry-run # print the plan, contact nothing # # Exit codes: # # 0 every check that ran found nothing # 1 findings, each one named # 2 NOTHING WAS CHECKED — unreachable, unconfigured, or curl missing. Not a # pass: a site that could not be contacted and a site with no problems must # never exit the same way. # # =========================================================================== # TEMPLATE COPY — configure this before the first run # =========================================================================== # # Set PREFLIGHT_ORIGIN to the one origin this copy is allowed to test. # # Assumes: bash, coreutils, `curl`. # # ## Why this exists # # These are the findings that people who audit applications of this kind report # seeing over and over, and every one of them is mechanical: a header that is # absent, a scheme that is plain, a login that answers a thousand guesses, a # reset form that confirms which addresses have accounts. None needs to be # understood to be checked, which is exactly why it belongs in a script rather # than in a page somebody re-reads before each release and then does not. # # *(precautionary)*: none of this has bitten a project here yet. It is included # because the checks are cheap and the evidence for them is somebody else's. # # ## It refuses to run against anything but its own origin # # Two of these checks — repeated bad logins, and asking whether an account # exists — are indistinguishable from an attack in somebody's log, and one of # them deliberately generates failed authentications. So there is no URL # argument that can point this anywhere: the target comes from PREFLIGHT_ORIGIN # and a URL passed on the command line must match it, or the run is refused. # # That is `status.sh`'s argument for having no --host flag, with more at stake: # there, a mistake reads the wrong machine, and here it hammers somebody else's # login form from your address. # # 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 # `default-src *` is a CSP. This reports presence, which is the part that is # mechanically checkable, and a present-but-useless policy is a job for a person # or for securityheaders.com's grade. Absence is the common case and the one # this catches. set -uo pipefail say() { printf '\033[1mpreflight:\033[0m %s\n' "$*" >&2; } die() { printf '\033[1mpreflight:\033[0m %s\n' "$*" >&2; exit 2; } # --------------------------------------------------------------------------- # CONFIGURATION — set this one, then delete this banner. # # The single origin this copy may contact, e.g. https://app.example.com. # Empty on purpose: a default here is a script that attacks whatever origin the # project it was copied from happened to use. # --------------------------------------------------------------------------- ORIGIN="${PREFLIGHT_ORIGIN:-https://qn.isnull.dev}" # Paths used only by --auth. Unset means that check reports "not configured" # rather than guessing at /login, which on the wrong app is a POST to something # else entirely. LOGIN_PATH="${PREFLIGHT_LOGIN_PATH:-}" RESET_PATH="${PREFLIGHT_RESET_PATH:-}" ATTEMPTS="${PREFLIGHT_LOGIN_ATTEMPTS:-12}" AUTH="" DRY_RUN="" TARGET="" while [ $# -gt 0 ]; do case "$1" in --auth) AUTH="yes"; shift ;; --dry-run) DRY_RUN="yes"; shift ;; -h|--help) say "usage: bash scripts/preflight.sh [--auth] [--dry-run] []" say " must match PREFLIGHT_ORIGIN. Set PREFLIGHT_LOGIN_PATH" say " and PREFLIGHT_RESET_PATH for --auth." exit 0 ;; -*) die "unknown argument '$1'." ;; *) TARGET="$1"; shift ;; esac done command -v curl >/dev/null 2>&1 || die "curl is not on PATH, so nothing could be checked." [ -n "$ORIGIN" ] || die "set PREFLIGHT_ORIGIN — the one origin this copy may contact. See the CONFIGURATION block." host_of() { printf '%s' "$1" | sed -E 's#^[a-zA-Z]+://##; s#/.*$##; s#:.*$##'; } ORIGIN_HOST="$(host_of "$ORIGIN")" [ -n "$ORIGIN_HOST" ] || die "PREFLIGHT_ORIGIN '$ORIGIN' does not look like a URL." if [ -n "$TARGET" ]; then TARGET_HOST="$(host_of "$TARGET")" if [ "$TARGET_HOST" != "$ORIGIN_HOST" ]; then say "refusing: '$TARGET' is host '$TARGET_HOST', and this copy is configured" say " for '$ORIGIN_HOST'." say "Two of these checks generate failed logins and look like an attack in" say "somebody's log. Pointing them at a host this copy was not configured" say "for is the mistake the refusal exists to prevent — change" say "PREFLIGHT_ORIGIN deliberately if you mean it." exit 2 fi else TARGET="$ORIGIN" fi case "$ATTEMPTS" in ''|*[!0-9]*) die "PREFLIGHT_LOGIN_ATTEMPTS must be a whole number, got '$ATTEMPTS'." ;; esac if [ -n "$DRY_RUN" ]; then say "--dry-run: nothing was contacted. Target: $TARGET" say "would run:" printf ' curl -sSI %s # headers, TLS\n' "$TARGET" >&2 printf ' curl -sSI http://%s/ # is plaintext served or redirected\n' "$ORIGIN_HOST" >&2 if [ -n "$AUTH" ]; then printf ' %s POSTs of bad credentials to %s\n' "$ATTEMPTS" "${LOGIN_PATH:-}" >&2 printf ' 1 reset request for an address that does not exist to %s\n' "${RESET_PATH:-}" >&2 else printf ' (--auth not given: the two active checks are skipped)\n' >&2 fi exit 0 fi findings=0 finding() { printf ' FINDING %s\n' "$*" >&2; findings=$((findings + 1)); } ok() { printf ' ok %s\n' "$*" >&2; } skip() { printf ' skipped %s\n' "$*" >&2; } # --------------------------------------------------------------------------- # Passive: one request, several answers. # --------------------------------------------------------------------------- HEADERS=$(curl -sS -I -L --max-time 20 "$TARGET" 2>/dev/null) \ || die "could not reach $TARGET. Nothing was checked — this is not a report that the site is fine." [ -n "$HEADERS" ] || die "$TARGET returned no headers. Nothing was checked." 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 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 # Either header answers the framing question; frame-ancestors is the modern one # 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 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 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 # Plain HTTP: a redirect is the right answer; a 200 is a site served in the clear. PLAIN=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 "http://$ORIGIN_HOST/" 2>/dev/null || printf 'none') case "$PLAIN" in 30[0-9]) ok "plain http redirects ($PLAIN)" ;; none) skip "plain http did not answer at all, which is also fine" ;; 200) finding "plain http answered 200 — the site is served unencrypted as well as encrypted" ;; *) skip "plain http answered $PLAIN, which is neither a redirect nor a page" ;; esac # --------------------------------------------------------------------------- # Active, and only with --auth. These generate failed authentications. # --------------------------------------------------------------------------- if [ -z "$AUTH" ]; then skip "rate limiting and account enumeration (pass --auth, and read what it does first)" else if [ -z "$LOGIN_PATH" ]; then skip "rate limiting — PREFLIGHT_LOGIN_PATH unset, and guessing at /login POSTs to whatever is there" else say "sending $ATTEMPTS failed logins to ${TARGET%/}$LOGIN_PATH — this will appear in the logs" limited="" for i in $(seq 1 "$ATTEMPTS"); do code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 \ -X POST -H 'Content-Type: application/json' \ --data '{"email":"preflight-probe@example.invalid","password":"not-a-real-password"}' \ "${TARGET%/}$LOGIN_PATH" 2>/dev/null || printf 'none') case "$code" in 429|423) limited="$code at attempt $i"; break ;; esac done if [ -n "$limited" ]; then ok "authentication is rate limited ($limited)" else finding "$ATTEMPTS failed logins in a row, no 429 and no lockout — a password list can be run against this overnight" fi fi if [ -z "$RESET_PATH" ]; then skip "account enumeration — PREFLIGHT_RESET_PATH unset" else BODY=$(curl -sS --max-time 10 -X POST -H 'Content-Type: application/json' \ --data '{"email":"definitely-not-registered-preflight@example.invalid"}' \ "${TARGET%/}$RESET_PATH" 2>/dev/null || printf '') if printf '%s' "$BODY" | grep -qiE "no (account|user)|not (found|registered)|does not exist|unknown email"; then finding "the reset endpoint says an unregistered address is unknown — that confirms which addresses DO have accounts, which is the input to a phishing or credential-stuffing list" else ok "the reset endpoint does not reveal whether the address is registered" fi fi fi say "" if [ "$findings" -gt 0 ]; then say "$findings finding(s) above." exit 1 fi say "no findings from the checks that ran."