diff --git a/.github/workflows/dns-origin-audit.yml b/.github/workflows/dns-origin-audit.yml index fe44809..1f6d762 100644 --- a/.github/workflows/dns-origin-audit.yml +++ b/.github/workflows/dns-origin-audit.yml @@ -32,6 +32,11 @@ on: push: paths: - 'allowed-origins.txt' + # A change to the approvals list changes what the sweep reports, so it has + # to re-run the sweep. Without this the file is edited, the daily job is + # green, and nobody learns that the edit either excused a credential + # surface or started reporting a legitimate service. + - 'approved-login-hosts.txt' - 'scripts/dns-origin-audit.sh' - 'scripts/edge-redirect-check.sh' - 'scripts/test-dns-origin-audit.sh' diff --git a/approved-login-hosts.txt b/approved-login-hosts.txt new file mode 100644 index 0000000..f2b2d95 --- /dev/null +++ b/approved-login-hosts.txt @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# +# approved-login-hosts.txt — the hostnames where a cPanel login form is EXPECTED. +# Consumed by scripts/edge-redirect-check.sh, beside allowed-origins.txt. +# +# WHY: webmail.jewell.nexus served a credential-capture login form from a server +# the family no longer rented. login_surface() catches that shape — but by +# itself it cannot tell a hijacked name from a service doing its job, so with no +# policy it reports every login form it ever finds. This estate has 33 reseller +# cPanel accounts, so a legitimate webmail./cpanel. name is a *when*, not an +# *if*; the day one appears, an unpoliced check is red every day for a +# known-good reason, and a gate that is red every day for a good reason is a gate +# nobody reads. That is how the September incident survived six months. +# +# So: +# login form on an APPROVED host -> not a finding. The service is working. +# login form on an UNAPPROVED host -> FINDING. This is the signal: a login +# surface on a name nobody meant to be +# serving one is exactly what +# webmail.jewell.nexus was. +# an APPROVED host serving NO login form -> not a finding either. Decided +# deliberately rather than defaulted: this +# file says "a form here is legitimate", +# not "a form here must exist". Making +# absence a finding would red the daily +# check whenever a webmail service is +# rebooting, moving or retired — the same +# permanent-red failure it exists to +# prevent. The run still prints the +# hostname, annotated, so the state is +# visible without being noisy. +# +# FORMAT (one per line) +# login a login form on this EXACT hostname is expected +# blank lines and # comments ignored +# +# EXACT HOSTNAMES ONLY. No suffixes and no wildcards: `login jewell.nexus` +# approves the apex, and nothing under it. A pattern is refused outright (exit +# 2) rather than interpreted, because "approve every hostname that matches" and +# "approve nothing" are both plausible readings, and guessing between them is +# how a policy becomes a hole. This is the same defect as a multi-tenant suffix +# on the origin allow-list — `suffix pages.dev` approves anyone's Pages site, +# not ours (issue #41) — and it is refused here on purpose. +# +# TO APPROVE A SERVICE, add its hostname — the name a visitor types, not the +# origin it resolves to: +# +# login webmail.jewell.nexus +# +# One line per service. Do NOT add a domain's worth of names "just in case": an +# approved name is a name whose login form this check will no longer report, and +# that is precisely the guarantee the estate lost in September. +# +# EMPTY TODAY, ON PURPOSE. Measured 2026-09-15: no webmail./cpanel./webdisk./whm. +# hostname answers anywhere in the estate, so there is nothing to approve — and +# approving an unverified name would pre-excuse a credential surface nobody has +# looked at. An empty list is the fail-safe direction: every login form found is +# reported until someone says, explicitly, that it belongs there. diff --git a/docs/plan-2026-09-25-detector-issues.adoc b/docs/plan-2026-09-25-detector-issues.adoc new file mode 100644 index 0000000..d8dd456 --- /dev/null +++ b/docs/plan-2026-09-25-detector-issues.adoc @@ -0,0 +1,230 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell += Detector issue sweep — plan for #41, #42, #43 +:toc: + +== What this is + +Three issues were filed against the two credential-free / read-only detectors +when the daily audit landed (PR #40), deliberately *not* fixed there so the +detector could start running. This is the plan for clearing them: the decisions +each needs, the order they can be taken in, and what has to exist before the +remaining one can honestly be called done. + +It is written down because two of the three are *dormant* — no hostname in the +estate currently exercises the code — and dormant defects are exactly the ones +that get "fixed" by reading the diff. Every claim below is either a control in +`+scripts/test-edge-redirect-check.sh+`, a mutant killed by one, or explicitly +marked as unmeasured. + +== The rule being applied + +The owner's standing rule, from PR #40: **dormant means an issue with acceptance +criteria; live-on-merge means fix it now.** All three are dormant as measured on +2026-09-15: + +* no `+webmail.+` / `+cpanel.+` / `+webdisk.+` / `+whm.+` hostname answers + anywhere in the estate — measured, 138 hostnames, `+RESULT: clean+` — so + `+login_surface+` never runs on a real sweep; +* no estate hostname redirects at all, so the probe-host/final-URL disagreement + has nothing to disagree about yet. + +Dormant is not the same as harmless. Each one becomes live on an event this +estate is *expecting*: 33 reseller cPanel accounts being migrated (a legitimate +login surface appears), a hostname redirecting, or a site being moved onto Pages +or Workers. The rule exists so that the fixing happens before the event rather +than during it. + +The recurring shape behind all three: **a guard that asks a different question +than its consumer.** #43 is that shape inside one function; #42 is that shape +between a detector and a policy that did not exist; #41 is that shape between an +allow-list and the ownership question the audit is supposed to answer. + +== The queue and its decisions + +=== #43 — `+login_surface+` judges one host and fetches another + +**Decision taken: the host judged is the host of the final URL.** + +The alternative — judge the probe host — was rejected because the check exists to +answer "what is served where the visitor lands?", which is also the URL that is +about to be fetched. Judging the probe host would mean never fetching a +credential surface reached by redirect (the direction that matters) and +body-checking a project homepage the check was never asked about (the direction +that generates noise). + +Done, with `+host_of_url()+` as the single place the fetched host is named: the +prefix test and the body fetch now read the same string from the same parser +reading the same URL. Two controls cover the two directions, one mutant reverts +the decision and is asserted to die on both, and the kill prints both verdicts so +a red says which half moved. + +=== #42 — an approved-login-host policy + +**Decision taken: an exact-hostname approvals list, empty by default, and +absence of a login form is not a finding.** + +* A login form on an **approved** host is not a finding: the service is working. +* A login form on an **unapproved** host is a finding. That is the signal — a + login surface on a name nobody meant to be serving one is precisely what + `+webmail.jewell.nexus+` was. +* An approved host serving **no** login form is **not** a finding either. The + issue asks for this one to be decided rather than defaulted, so: the list says + a form here is *legitimate*, not that one must *exist*. Making absence a + finding would red the daily check whenever a webmail service is rebooting, + moving or retired — the permanent-red failure the policy exists to prevent. + The run still prints the hostname, annotated, so the state is visible. + +The list is `+approved-login-hosts.txt+`, exact hostnames only, one +`+login +` line per service. A suffix or a wildcard is **refused with +exit 2 rather than interpreted**, because "approve every host that matches" and +"approve nothing" are both plausible readings and guessing between them is how a +policy becomes a hole. A `+suffix+` line copied over from `+allowed-origins.txt+` +is ignored *loudly* and approves nothing, so the list can only ever come out +stricter than the operator intended. + +It ships **empty**, which is not an oversight: nothing answers on a cPanel +service name today, and approving an unverified name would pre-excuse exactly +the credential surface this check exists to find. An empty list is also what +keeps the change inert on the current estate. + +=== #41 — a multi-tenant suffix proves no ownership + +**Decision: not fixed in the same change. Deferred deliberately, with the +interim design and the missing input named.** + +The acceptance criterion is easy to state: no bare multi-tenant suffix +(`+github.io+`, `+pages.dev+`, `+workers.dev+`) in `+allowed-origins.txt+`, and a +fixture aimed at `+not-ours.pages.dev+` produces a finding while a real estate +target does not. The blocker is that the replacement is **data**: + +* `+pages.dev+` is the hard one. Project names are globally unique but carry no + ownership marker — `+ours.pages.dev+` and `+anyone-elses.pages.dev+` are the + same shape — so the only correct entries are the exact project hostnames, and + those have to come from the Pages project inventory. +* `+workers.dev+` narrows to **one datum**: the account's own workers.dev + subdomain makes `+suffix .workers.dev+` ownership-bound, because only + that account can serve names under it. That subdomain is not recorded in this + repository. +* `+github.io+` narrows to an org-namespaced suffix — `+suffix + hyperpolymath.github.io+` — which is ownership-bound by the namespace itself. + +So the interim shape is: replace the three shared suffixes with the org-scoped +GitHub suffix, the account-scoped Workers suffix, and one `+host .pages.dev+` +line per Pages project — at which point any target not on the list becomes a +loud finding with the exact line needed to fix it. That is the "converts a silent +acceptance into a loud, obvious failure" option the issue describes, and it is +safe to take the moment those two data items exist. + +Why it is not taken blind: this is the one of the three whose failure direction +is *noise on a live estate*. Shipping it without the inventory would report +legitimate Pages and Workers targets as findings, and an audit that is red for a +known-good reason every day stops being read — which is the failure #42 exists to +prevent and the failure that let the September incident run for six months. + +Sequence: `+dns-origin-audit+` already reports every A record until +`+ALLOWED_ORIGIN_IPS+` is populated (documented in `+allowed-origins.txt+`), so +the origins gate is red today for an unrelated, already-tracked reason. #41 is +best taken *with* that population, as one pass over the zone list, rather than as +a second, earlier reason for the same gate to be red. + +== Order of work + +. **#43 + #42, one change** (this branch). They rewrite the same decision — the + issue for #43 says so explicitly — and doing them apart would mean writing the + policy against a function that is about to change which host it is about. +. **#41, with the inventory.** Blocked on two data items, not on design. +3. Estate equivalence after #1: the `+edge+` job runs the real 138-hostname + sweep on every push to these paths and daily; it is green today and the change + is inert unless a login-prefixed hostname answers or a chain lands on one. + +== How each claim is proven + +Measured on this branch (`+scripts/test-edge-redirect-check.sh+`, 113 controls, +was 90): + +[cols="1,3",options="header"] +|=== +|Claim |Evidence +|the two hosts agree |controls 34/35: chain into a login name is fetched and +reported (`+calls=3+`); chain out of one is not judged by the starting name +(`+calls=2+`, no body fetch) +|the fix is load-bearing |one-line reversion of the decision killed by both +controls, asserted by name, with both verdicts printed; the mutant is asserted +to differ from the shipped file and to parse, so the kill cannot be vacuous +|the policy bites |controls 38/39 are the same page with and without an +approval — they differ in nothing else, so neither can pass for the wrong reason +|the policy is not a hole |exact-host approval does not cover subdomains (41); +a `+suffix+` line approves nothing and says so (42); a wildcard is refused with +exit 2 (43); an absent file approves nothing (39) +|the body fetch cannot be misdirected |control 33: a pin belonging to another +host is refused rather than used +|the host comparison is not case-broken |control 45: a Location header that +capitalises the landing host is still fetched and judged — a case-sensitive +comparison anywhere in the chain silently turns a real finding into `+ok+` +|the detector still detects |the whole sweep, hermetically replayed through the +real script with a scripted curl and dig: 138 hostnames, unchanged row shape, +`+approved login hosts: 0+` +|=== + +Two reversions ship *in the suite itself*, with their kills asserted by name: +the decision alone (mutant A), and the decision plus the removed pin assertion, +which is the pre-fix code verbatim (mutant B). Ten further mutants were run by +hand against the policy, pin and prefix paths. Nine died, each at exactly the +control written for it: suffix-approval, absence-as-finding, approvals ignored, +pattern accepted, missing pin, pin/host mismatch, missing loader validation, +absent-file-approves-everything, emptied prefix list. The tenth is the first +entry under *Two results* below. + +Two results are recorded as something other than a clean kill, and both are in +the suite rather than in this paragraph: + +* Mutant A is *invisible* to the direction-two control, because the + pin-belongs-to-this-host assertion refuses the fetch before the wrong host can + be reached. That is a second guard holding the same invariant, not a hole — and + it is asserted (control 48), so the day it stops being true the suite says so + rather than leaving a comment that has quietly become false. +* Removing `+host_of_url+`'s https check kills nothing, because + `+url_host_port+` already refuses those URLs. The line is documented as a + contract assertion rather than as a defence, so no reader is told it guards + something it does not. + +**Not measured here:** the live 138-hostname sweep. This environment has no +network and no Cloudflare credential, so equivalence is argued from the +hermetic replay plus the fact that the change is inert unless a login-prefixed +hostname answers or a chain lands on one. The live answer is the `+edge+` job, +which runs on merge and daily — and it is the job whose colour would change if +this analysis is wrong. + +== What would reopen this + +* A hostname in the estate that legitimately serves a cPanel login form: add it + to `+approved-login-hosts.txt+`, one line, and the daily check stays quiet. +* A legitimate Pages or Workers target appearing in a zone: that is #41's + inventory, and it is the point at which the interim conversion becomes both + safe and necessary. +* Any estate hostname answering under a `+webmail.+` / `+cpanel.+` name that is + not on the approvals list: that is the check working, and the finding is the + conversation. + +== CI note: an unsigned HEAD commit, and the estate class behind it + +The first push of this change (`52f6117`) was unsigned, and this repository's +`Base` ruleset carries `required_signatures`. Per +`hyperpolymath/standards#1019`, an **unsigned HEAD commit makes GitHub reject +every workflow at startup** — `startup_failure`, zero jobs created — so +`gh pr checks` reads as "nothing red" rather than as an error. Measured on that +head with the probe from the same issue: 5 of 5 `github-actions` check suites +`startup_failure`, and `git log -1 --format=%G?` answering `N`. + +Only the HEAD commit is checked, so the cure is a signed commit at the tip +rather than a rewrite, and commits created through the GitHub API are signed by +GitHub (`verification.verified=true`). The branch was therefore rebuilt as a +single such commit. + +Two things worth keeping from this. First, the failure is invisible in exactly +the way this estate keeps paying for: no job, no check run, no red X — a dark +gate and a green one look the same from the outside. Second, a detector that +only ever runs in CI is not evidence about itself; the suites in this change are +hermetic so that they *can* be run anywhere, which is why this class cost +investigation time rather than verification time. diff --git a/scripts/edge-redirect-check.sh b/scripts/edge-redirect-check.sh index 7a66356..7446904 100755 --- a/scripts/edge-redirect-check.sh +++ b/scripts/edge-redirect-check.sh @@ -25,8 +25,16 @@ # EXIT CODES # 0 every hostname stayed on an estate domain # 1 at least one hostname left the estate, or served a login form from a -# hostname that should not have one +# hostname that is not on the approved-login-hosts.txt list # 2 usage/preflight error +# +# FILES +# domains.csv the estate's zones, one domain per line +# approved-login-hosts.txt "login " — a login form on this exact +# hostname is expected, so it is the service +# working rather than a finding. Exact hostnames +# only: no suffixes, no patterns. Absent file = +# nothing approved, which is the fail-safe default. set -uo pipefail @@ -305,34 +313,191 @@ walk_redirects() { # here so the credential-free fallback catches it too when no token exists. LOGIN_PREFIXES="webmail cpanel webdisk whm" +# --- the approved-login-host policy ------------------------------------------ +# +# A login form on a cPanel service name is a finding only if the service is not +# SUPPOSED to be there, and login_surface cannot tell the two apart by itself. +# Without a policy it reports every login form it finds, so the first legitimate +# webmail host in this estate (33 reseller cPanel accounts, so: when, not if) +# would turn the daily check permanently red — and a gate that is red every day +# for a known-good reason is a gate nobody reads. +# +# The list is EXACT hostnames, one `login ` line each, in +# approved-login-hosts.txt beside allowed-origins.txt. No suffixes and no +# patterns, on purpose: a suffix on a shared platform approves anyone's host, +# not ours — the defect recorded for the origin allow-list in #41. +APPROVED_LOGIN_HOSTS_FILE="${APPROVED_LOGIN_HOSTS_FILE:-$REPO_ROOT/approved-login-hosts.txt}" +APPROVED_LOGIN_HOSTS=() + +# load_approved_login_hosts — read APPROVED_LOGIN_HOSTS_FILE into the array. +# +# A MISSING file yields an empty list, which approves nothing. That is both the +# fail-safe direction (an unapproved host is reported, never silently excused) +# and the estate's measured state today: no webmail./cpanel./webdisk./whm. +# hostname answers anywhere. It is deliberately NOT a preflight error: refusing +# to run would delete the off-estate detector over a policy file, and a detector +# that will not look is the failure this whole workflow exists to prevent. +# +# A MALFORMED entry is exit 2, because the readings of `login *.jewell.nexus` +# are "approve every hostname that matches" and "approve nothing", and guessing +# between them is how a policy becomes a hole. Shape-validated as a single +# hostname, so a wildcard, a suffix and a bare kind word all fail loudly here +# instead of silently never matching the hostname they were meant to approve. +load_approved_login_hosts() { + APPROVED_LOGIN_HOSTS=() + [[ -f "$APPROVED_LOGIN_HOSTS_FILE" ]] || return 0 + local line kind val + while IFS= read -r line; do + line="${line%%#*}" + line="$(echo "$line" | xargs 2>/dev/null || true)" + [[ -z "$line" ]] && continue + kind="${line%% *}" + val="${line#* }" + case "$kind" in + login) + val="${val,,}" + if [[ ! "$val" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$ ]]; then + echo "error: $APPROVED_LOGIN_HOSTS_FILE: '$val' is not a hostname." >&2 + echo "hint: approval is per EXACT hostname, one line per service, e.g." >&2 + echo " login webmail.jewell.nexus" >&2 + echo " A wildcard ('login *.jewell.nexus') would approve every host" >&2 + echo " that matches it, including a stranger's — the multi-tenant" >&2 + echo " mistake this list must not repeat. Refusing rather than" >&2 + echo " guessing which reading was meant." >&2 + exit 2 + fi + APPROVED_LOGIN_HOSTS+=("$val") ;; + *) + echo "warn: ignoring unrecognised approved-login-hosts.txt line: $line" >&2 ;; + esac + done < "$APPROVED_LOGIN_HOSTS_FILE" +} + +# is_approved_login_host HOST -> 0 when a login form on HOST is expected. +# Exact match on the whole hostname; approving `jewell.nexus` approves the apex +# and nothing under it. +is_approved_login_host() { + local host="${1,,}" entry + for entry in "${APPROVED_LOGIN_HOSTS[@]+"${APPROVED_LOGIN_HOSTS[@]}"}"; do + [[ "$host" == "$entry" ]] && return 0 + done + return 1 +} + +# host_of_url URL -> prints the host (no port), lowercased; returns 1 if there +# is none. THIN WRAPPER ON PURPOSE: it goes through url_host_port, the same +# parser the walk used to build the pin, so the host that is JUDGED and the host +# that is FETCHED are the same string from the same parser reading the same URL. +# Two parsers — or two URLs — is the #43 defect: the prefix test read the probe +# host while the body fetch read the final URL, so after a redirect this +# function declined to look at the credential surface it had just walked into, +# and would have body-checked a project homepage it was never asked about. +# +# The https check is an ASSERTION OF THE CONTRACT, not a defence: url_host_port +# already yields no host at all for a non-https URL (its port field ends up +# non-numeric), so removing this line kills no control — measured, mutant M8. +# It is here so that a future caller which hands over a URL the walk never +# validated gets a refusal rather than a hostname, and it is labelled as that +# rather than as a guard, because a comment claiming a property the code does +# not establish is this estate's most-repeated defect. +host_of_url() { + local url="$1" + [[ "$url" == https://* ]] || return 1 + url_host_port "$url" || return 1 + printf '%s' "${URL_HOST,,}" +} + +# login_surface PROBE_HOST CODE FINAL_URL PIN -> 0 means FINDING. +# +# PROBE_HOST the hostname the sweep started from. Used ONLY to describe a +# finding in which the chain moved host; never to decide one. +# CODE the status of the last hop. +# FINAL_URL where the chain landed. The host judged is this URL's host, +# because the question is "what is served where the visitor +# lands?" — and because that is the host about to be fetched. +# PIN the --resolve pin the walk built for FINAL_URL. Without one +# this function refuses to fetch at all. +# +# Sets LOGIN_STATE for the caller's report: +# finding a login form on a host nobody approved — the actual signal +# expected an approved login host serving a login form — the service working +# absent an approved login host serving no login form — not a finding +# none nothing to report +# +# The old version walked the chain a second time and clobbered the caller's +# WALK_* globals; this one fetches the caller's already-validated URL exactly +# once, through the caller's pin. login_surface() { - local host="$1" code="$2" final="$3" pin="$4" prefix body + local probe="$1" code="$2" final="$3" pin="$4" + local judged="" matched="" prefix body="" pin_host="" + LOGIN_STATE="none"; LOGIN_NOTE="" [[ "$code" == "200" ]] || return 1 + + # ONE HOST, READ ONCE. This is the whole of #43: the guard and its consumer + # must ask their question about the same value, and this is that value — the + # host of the URL the caller walked and validated, which is the URL about to + # be fetched. + judged="$(host_of_url "$final")" || return 1 + + # The body is fetched only for a cPanel service NAME — still gated on the + # name, but on the name that is about to be fetched. for prefix in $LOGIN_PREFIXES; do - if [[ "$host" == "$prefix."* ]]; then - # Reuse the caller's pin rather than resolving $final again. An - # independent second resolution was the defect here: the address - # validated during the walk could be replaced by a private one before - # this fetch. With the pin there is no second resolution to poison. - # No pin means the caller never validated this URL, so refuse to fetch. - [[ -n "$pin" ]] || return 1 - body="$(curl -sS --max-time "$TIMEOUT" --proto '=https' --max-redirs 0 \ - --noproxy '*' --resolve "$pin" "$final" 2>/dev/null || true)" - # cPanel/Webmail login markers. Kept broad on purpose: a false positive - # costs one manual look, a false negative costs a mailbox. - if grep -qiE 'webmail login|cpanel login|name="?pass(word)?"?|id="?login_password' \ - <<< "$body"; then - return 0 - fi - return 1 - fi + if [[ "$judged" == "$prefix."* ]]; then matched="$prefix"; break; fi done + [[ -n "$matched" ]] || return 1 + + # Reuse the caller's pin rather than resolving $final again. An independent + # second resolution was the defect here: the address validated during the + # walk could be replaced by a private one before this fetch. With the pin + # there is no second resolution to poison — and it has to belong to the URL + # being fetched, or the fetch would go out through a pin that validated a + # different address. + [[ -n "$pin" ]] || return 1 + # Case-insensitively: the walk builds the pin from the URL it was handed, and a + # Location header may spell the host in any case, while hostnames are + # case-insensitive and this function compares lower-cased ones. Comparing + # byte-for-byte here would refuse to fetch a real credential surface whose + # redirect happened to capitalise it — a false negative produced by the + # assertion meant to make the fetch safer, which is why control 45 exists. + pin_host="${pin%%:*}" + [[ "${pin_host,,}" == "$judged" ]] || return 1 + + body="$(curl -sS --max-time "$TIMEOUT" --proto '=https' --max-redirs 0 \ + --noproxy '*' --resolve "$pin" "$final" 2>/dev/null || true)" + + # cPanel/Webmail login markers. Kept broad on purpose: a false positive + # costs one manual look, a false negative costs a mailbox. + if grep -qiE 'webmail login|cpanel login|name="?pass(word)?"?|id="?login_password' \ + <<< "$body"; then + if is_approved_login_host "$judged"; then + LOGIN_STATE="expected" + return 1 # an approved service working is not a finding + fi + LOGIN_STATE="finding" + if [[ "$probe" != "$judged" ]]; then + LOGIN_NOTE="at $judged, reached by redirect; not an approved login host" + else + LOGIN_NOTE="not an approved login host" + fi + return 0 + fi + + # A login-prefixed name serving no login form is not a finding whether or not + # it is approved. See approved-login-hosts.txt: the list says a form here is + # legitimate, not that one must exist. + if is_approved_login_host "$judged"; then LOGIN_STATE="absent"; fi return 1 } +load_approved_login_hosts +login_hosts_note="" +[[ -f "$APPROVED_LOGIN_HOSTS_FILE" ]] \ + || login_hosts_note=" ($APPROVED_LOGIN_HOSTS_FILE absent — nothing is pre-approved)" + FINDINGS=0 echo "== edge redirect check — $(date -u +%Y-%m-%dT%H:%M:%SZ) ==" echo "hostnames: ${#HOSTS[@]}" +echo "approved login hosts: ${#APPROVED_LOGIN_HOSTS[@]}$login_hosts_note" echo for h in "${HOSTS[@]}"; do @@ -364,8 +529,12 @@ for h in "${HOSTS[@]}"; do if on_estate "$final"; then if login_surface "$h" "$code" "$final" "$pin"; then - printf ' %-34s %s LOGIN FORM served here\n' "$h" "$code" + printf ' %-34s %s LOGIN FORM served here — %s\n' "$h" "$code" "$LOGIN_NOTE" FINDINGS=$((FINDINGS + 1)) + elif [[ "$LOGIN_STATE" == "expected" ]]; then + printf ' %-34s %s ok (approved login host — login form served, as expected)\n' "$h" "$code" + elif [[ "$LOGIN_STATE" == "absent" ]]; then + printf ' %-34s %s ok (approved login host — no login form served today)\n' "$h" "$code" else printf ' %-34s %s ok\n' "$h" "$code" fi @@ -385,9 +554,11 @@ if [[ "$FINDINGS" -gt 0 ]]; then echo "DNS UNRESOLVABLE = every resolver errored, so this hostname was NOT CHECKED." echo " It is reported rather than passed over, because an" echo " unanswered question is not a clean answer." - echo "LOGIN FORM = that hostname serves a password field; if its origin is a" - echo " server we have left, the password goes to a stranger. Worse" - echo " than a redirect, so fix it first." + echo "LOGIN FORM = that hostname serves a password field and is NOT on the" + echo " approved-login-hosts.txt list. If its origin is a server we" + echo " have left, the password goes to a stranger. Worse than a" + echo " redirect, so fix it first — or add the hostname to that list," + echo " explicitly, if a login form there is intended." echo "Check the Cloudflare DNS Content column for those names: a proxied record" echo "hides its origin, so the public A record will look innocent." exit 1 diff --git a/scripts/test-edge-redirect-check.sh b/scripts/test-edge-redirect-check.sh index b09746e..03c333d 100755 --- a/scripts/test-edge-redirect-check.sh +++ b/scripts/test-edge-redirect-check.sh @@ -63,10 +63,11 @@ bad() { printf 'FAIL %s\n %s\n' "$1" "$2"; FAIL=$((FAIL + 1)); } # vacuously — the exact absence-shaped failure this incident kept producing — so # the extraction is itself asserted before anything is run. -sed -n '/^is_ip_literal() {$/,/^}$/p; /^is_private_ip() {$/,/^}$/p; /^url_host_port() {$/,/^}$/p; /^resolve_public() {$/,/^}$/p; /^walk_redirects() {$/,/^}$/p; /^login_surface() {$/,/^}$/p; /^LOGIN_PREFIXES=/p; /^DNS_FALLBACKS=/p' \ +sed -n '/^is_ip_literal() {$/,/^}$/p; /^is_private_ip() {$/,/^}$/p; /^url_host_port() {$/,/^}$/p; /^resolve_public() {$/,/^}$/p; /^walk_redirects() {$/,/^}$/p; /^host_of_url() {$/,/^}$/p; /^is_approved_login_host() {$/,/^}$/p; /^load_approved_login_hosts() {$/,/^}$/p; /^login_surface() {$/,/^}$/p; /^LOGIN_PREFIXES=/p; /^DNS_FALLBACKS=/p' \ "$SUT" > "$WORK/sut.bash" -for fn in is_ip_literal is_private_ip url_host_port resolve_public walk_redirects login_surface; do +for fn in is_ip_literal is_private_ip url_host_port resolve_public walk_redirects \ + host_of_url is_approved_login_host load_approved_login_hosts login_surface; do grep -q "^${fn}() {$" "$WORK/sut.bash" \ || { echo "preflight: did not extract ${fn}() from $SUT" >&2; exit 2; } done @@ -135,6 +136,20 @@ case "${STUB_MODE:-ok200}" in loop) echo "301 203.0.113.10 https://next-${n}.example.com/" ;; onehop) if [[ "$n" == 1 ]]; then echo "301 203.0.113.10 https://final.example.com/" else echo "200 203.0.113.11"; fi ;; + # A chain that MOVES HOST, which is the situation #43 is about, and the two + # directions of it. The probe host and the landing host are set here; the page + # served at the landing host is whatever STUB_BODY names, so a control can pair + # "landed on a cPanel name" with "and that name serves a login form" and get + # both halves of the defect in one run. + to_webmail) if [[ "$n" == 1 ]]; then echo "301 203.0.113.10 https://webmail.example.com/" + else echo "200 203.0.113.11"; fi ;; + to_www) if [[ "$n" == 1 ]]; then echo "301 203.0.113.10 https://www.example.com/" + else echo "200 203.0.113.11"; fi ;; + # The same landing host as to_webmail, spelled the way a Location header is + # allowed to spell it. Hostnames are case-insensitive; a comparison between the + # judged host and the pin that is not, refuses to fetch and reports nothing. + to_webmail_mixed) if [[ "$n" == 1 ]]; then echo "301 203.0.113.10 https://Webmail.Example.COM/" + else echo "200 203.0.113.11"; fi ;; *) echo "stub: unknown STUB_MODE '${STUB_MODE:-}'" >&2; exit 99 ;; esac STUB @@ -205,6 +220,14 @@ export DIG_COUNT="$WORK/digs" # shellcheck disable=SC2034 # both are read by the functions sourced below TIMEOUT=20 MAX_HOPS=5 +# Where the approved-login-host policy is read from. The suite does NOT source +# the script's own default: `${VAR:-$REPO_ROOT/...}` would expand REPO_ROOT, +# which is unset here under `set -u`, so the suite sets the path it means to +# test — and every control that is not about the policy runs against a path that +# does not exist, which is the ABSENT-FILE case (approves nothing) rather than a +# case nobody chose. +APPROVED_LOGIN_HOSTS_FILE="$WORK/no-such-approved-login-hosts.txt" +APPROVED_LOGIN_HOSTS=() # shellcheck source=/dev/null source "$WORK/sut.bash" @@ -545,7 +568,6 @@ else "recorded pins: '$(cat "$STUB_RESOLVE")'" fi - # --- A dig diagnostic is not a DNS answer ------------------------------------ # # 25. `dig +short` writes its own diagnostics to STDOUT. The previous filter was @@ -670,6 +692,354 @@ else "$np_ok of $np_total invocation(s) carried --noproxy '*' (walk=$np_walk body=$np_body); recorded: $(sort "$STUB_NOPROXY" | uniq -c | tr '\n' ';')" fi +# 33. The pin has to belong to the URL being fetched. A pin built for one host +# handed to a fetch of another means the request goes out through an address +# validated for something else — the same guard/consumer mismatch as #43, one +# layer down, so it is refused rather than trusted. +unset STUB_BODY; : > "$STUB_COUNT" +if ! login_surface "webmail.example.com" 200 "https://webmail.example.com/" \ + "www.example.com:443:203.0.113.10" \ + && [[ "$(cat "$STUB_COUNT")" == "" ]]; then + ok "refuses to fetch through a pin that belongs to a different host" +else + bad "refuses to fetch through a pin that belongs to a different host" \ + "calls='$(cat "$STUB_COUNT")'" +fi + + +# --- #43: the host that is judged is the host that is fetched ----------------- +# +# #43 was the prefix test reading the PROBE host while the body fetch read the +# FINAL URL. The two directions of that mismatch lead to opposite faults, so +# there is one control each. `verdict` runs the walk and then the login decision +# the way the report loop does, so what is measured is the pairing, not either +# half of it: +# +# a chain www. -> webmail. must be FETCHED and reported (a credential +# surface reached by redirect used to be skipped) +# a chain webmail. -> www. must NOT be fetched or judged (a homepage used to +# be matched against login markers) +# +# Both controls pass STUB_BODY=m_webmail, a page that DOES match the markers, so +# a stray fetch anywhere in the scenario changes the verdict rather than passing +# unnoticed. `calls=` is reported because the request count is what separates +# "decided not to look" from "looked and found nothing" — the distinction this +# whole estate keeps relearning. + +verdict() { + ( set -uo pipefail + # shellcheck source=/dev/null + source "$1" + # EVERY scenario variable is set here, including the resolver: these are + # exported for the whole suite, so one control inheriting another's residue + # is how a control quietly stops measuring its own scenario. (Measured: the + # first run of this helper inherited STUB_DIG=none from control 31, resolved + # nothing, and reported `000` for a hostname that answers.) + export STUB_MODE="$3" STUB_BODY="$4" STUB_DIG="${5:-public}" + : > "$STUB_COUNT"; : > "$STUB_RESOLVE"; : > "$DIG_COUNT" + walk_redirects "$2" || exit 9 + login_surface "$(host_of_url "$2" 2>/dev/null)" "$WALK_CODE" "$WALK_FINAL" "$WALK_PIN" + printf 'rc=%s state=%s calls=%s' "$?" "${LOGIN_STATE:-}" "$(cat "$STUB_COUNT")" + ) 2>/dev/null +} + +echo +echo "--- #43: the host judged is the host fetched" + +# 34. DIRECTION ONE. calls=3 is the load-bearing part: two hops plus the body. +# The pre-fix code made two, never looked at the credential surface, and +# reported the hostname ok. +v="$(verdict "$WORK/sut.bash" "https://www.example.com/" to_webmail m_webmail)" +if [[ "$v" == "rc=0 state=finding calls=3" ]]; then + ok "a chain landing on an unapproved login host is fetched and reported ($v)" +else + bad "a chain landing on an unapproved login host is fetched and reported" \ + "got '$v', want 'rc=0 state=finding calls=3'" +fi + +# 35. DIRECTION TWO. calls=2 is the whole walk and no body fetch. The pre-fix +# code made three: it matched the probe name, fetched the homepage, matched +# a login marker in it and reported a hostname serving an ordinary page. +v="$(verdict "$WORK/sut.bash" "https://webmail.example.com/" to_www m_webmail)" +if [[ "$v" == "rc=1 state=none calls=2" ]]; then + ok "a chain leaving a login host for a homepage is not judged by the starting name ($v)" +else + bad "a chain leaving a login host for a homepage is not judged by the starting name" \ + "got '$v', want 'rc=1 state=none calls=2'" +fi + + +# --- #42: the approved-login-host policy --------------------------------------- +# +# The policy's entire content is WHICH hostnames are approved, so the controls +# below drive it from fixtures rather than from the (deliberately empty) estate +# list: a control that read today's inventory would change meaning the day the +# estate changes, which is how a suite stops measuring the code. + +echo +echo "--- #42: the approved-login-host policy" + +printf 'login webmail.example.com\n' > "$WORK/approved.txt" +printf 'login *.jewell.nexus\n' > "$WORK/approved-pattern.txt" +printf 'suffix jewell.nexus\n' > "$WORK/approved-suffix.txt" + +# 36. The shipped policy file must exist, and the script must be the thing that +# names it. An approvals list at a path nobody reads is a policy that +# silently approves nothing: fail-safe, but invisible, and this file is +# documented as the place the decision lives. +if [[ -f "$HERE/../approved-login-hosts.txt" ]] \ + && grep -qF 'approved-login-hosts.txt' "$SUT"; then + ok "the shipped approvals file exists and the script names it" +else + bad "the shipped approvals file exists and the script names it" \ + "file or reference missing — approvals would silently apply to nothing" +fi + +# 37. The loader reads the list it is given, and nothing else. +APPROVED_LOGIN_HOSTS_FILE="$WORK/approved.txt" +load_approved_login_hosts +if [[ "${#APPROVED_LOGIN_HOSTS[@]}" == "1" \ + && "${APPROVED_LOGIN_HOSTS[0]}" == "webmail.example.com" ]]; then + ok "loads exactly the approved hostname it was given" +else + bad "loads exactly the approved hostname it was given" \ + "got ${#APPROVED_LOGIN_HOSTS[@]}: ${APPROVED_LOGIN_HOSTS[*]:-}" +fi + +# 38. An approved host serving a login form is the SERVICE WORKING, not a +# finding. Without this, the policy is a file nobody reads. +if ! run_login m_webmail "webmail.example.com" 200 "https://webmail.example.com/" \ + && [[ "$LOGIN_STATE" == "expected" ]]; then + ok "an approved login host serving a login form is not a finding" +else + bad "an approved login host serving a login form is not a finding" \ + "state='${LOGIN_STATE:-}'" +fi + +# 39. And the SAME page on an unapproved host IS a finding. Controls 38 and 39 +# differ in nothing but the list, which is what makes them a pair: either one +# alone is satisfied by a function that always returns the same verdict. The +# path used here does not exist on purpose — an ABSENT approvals file must +# approve nothing, and that is asserted rather than assumed. +APPROVED_LOGIN_HOSTS_FILE="$WORK/no-such-approved-login-hosts.txt" +load_approved_login_hosts +if run_login m_webmail "webmail.example.com" 200 "https://webmail.example.com/" \ + && [[ "$LOGIN_STATE" == "finding" ]]; then + ok "the same login form on an unapproved host is a finding" +else + bad "the same login form on an unapproved host is a finding" \ + "state='${LOGIN_STATE:-}'" +fi + +# 40. An approved host serving NO login form is not a finding, and says so. The +# decision is recorded in approved-login-hosts.txt: the list says a form here +# is legitimate, not that one must exist, so a webmail service rebooting or +# being retired does not red the daily check — the permanent-red failure the +# policy exists to prevent. It is still reported, annotated, so the state is +# visible. +APPROVED_LOGIN_HOSTS_FILE="$WORK/approved.txt" +load_approved_login_hosts +if ! run_login benign "webmail.example.com" 200 "https://webmail.example.com/" \ + && [[ "$LOGIN_STATE" == "absent" ]]; then + ok "an approved login host serving no login form is not a finding, and says so" +else + bad "an approved login host serving no login form is not a finding" \ + "state='${LOGIN_STATE:-}'" +fi + +# 41. Approving a HOSTNAME is not approving a NAMESPACE. `login jewell.nexus` +# must not approve webmail.jewell.nexus: the subdomain is a different name, +# answered by a different vhost, and a specific name is what was hijacked at +# webmail.jewell.nexus. This is the #41 lesson applied before it can be +# relearned here. +printf 'login jewell.nexus\n' > "$WORK/approved-apex.txt" +APPROVED_LOGIN_HOSTS_FILE="$WORK/approved-apex.txt" +load_approved_login_hosts +if is_approved_login_host jewell.nexus && ! is_approved_login_host webmail.jewell.nexus; then + ok "an exact hostname approval does not approve the names under it" +else + bad "an exact hostname approval does not approve the names under it" \ + "apex approved=$([[ -n "${APPROVED_LOGIN_HOSTS[0]:-}" ]] && echo yes); subdomain approved too=$(is_approved_login_host webmail.jewell.nexus && echo yes || echo no)" +fi + +# 42. A `suffix` line is NOT an approval, and must not become one by being read +# as "everything under this domain". It is ignored LOUDLY, so the list can +# only ever come out stricter than the operator intended — never broader. +APPROVED_LOGIN_HOSTS_FILE="$WORK/approved-suffix.txt" +load_approved_login_hosts 2>"$WORK/loader-suffix.err" +if [[ "${#APPROVED_LOGIN_HOSTS[@]}" -eq 0 ]] \ + && grep -q "unrecognised" "$WORK/loader-suffix.err"; then + ok "a suffix line approves nothing, and says so out loud" +else + bad "a suffix line approves nothing, and says so out loud" \ + "approved=${#APPROVED_LOGIN_HOSTS[@]} stderr='$(cat "$WORK/loader-suffix.err")'" +fi + +# 43. A pattern is REFUSED, not guessed at. `login *.jewell.nexus` has two +# plausible readings — approve every host that matches, or approve nothing — +# and a policy that silently picks one is how a stranger's login surface gets +# pre-approved. Exit 2, and the message says which mistake it is. +( APPROVED_LOGIN_HOSTS_FILE="$WORK/approved-pattern.txt" load_approved_login_hosts ) \ + 2>"$WORK/loader-pattern.err" +rc=$? +if [[ "$rc" -eq 2 ]] && grep -q "not a hostname" "$WORK/loader-pattern.err"; then + ok "a wildcard entry is refused with exit 2 rather than interpreted" +else + bad "a wildcard entry is refused with exit 2 rather than interpreted" \ + "rc=$rc stderr='$(cat "$WORK/loader-pattern.err")'" +fi + +# 44. host_of_url is the one place the fetched host is named, so its parsing is +# asserted directly: the port is not part of a hostname, and a hostname is +# case-insensitive while the approvals list is written in lower case. +for spec in "https://a.example.com/|a.example.com" \ + "https://a.example.com:8443/x?y=1|a.example.com" \ + "https://[2001:db8::1]:8443/|2001:db8::1" \ + "https://user:pw@Webmail.Example.COM/|webmail.example.com"; do + url="${spec%%|*}"; want="${spec##*|}" + got="$(host_of_url "$url")" || got="" + if [[ "$got" == "$want" ]]; then + ok "host_of_url $url -> $want" + else + bad "host_of_url $url" "got '$got', want '$want'" + fi +done +if ! host_of_url "http://plain.example.com/" >/dev/null \ + && ! host_of_url "https:///path" >/dev/null; then + ok "host_of_url refuses a non-https URL and a URL with no host" +else + bad "host_of_url refuses a non-https URL and a URL with no host" \ + "one of the two was accepted, so a host could be judged that was never walked" +fi + +# 45. A hostname is case-insensitive and the approvals list is written in lower +# case, so the walk, the judgement and the pin must agree ACROSS case. A +# comparison that is case-sensitive in one place and not the other refuses to +# fetch a real credential surface whose Location header capitalised it — a +# false negative manufactured by the assertion meant to make the fetch safer. +v="$(verdict "$WORK/sut.bash" "https://www.example.com/" to_webmail_mixed m_webmail)" +if [[ "$v" == "rc=0 state=finding calls=3" ]]; then + ok "a mixed-case landing host is judged and fetched, not skipped ($v)" +else + bad "a mixed-case landing host is judged and fetched, not skipped" \ + "got '$v', want 'rc=0 state=finding calls=3'" +fi + +# --- the fix is load-bearing: reverting it must kill these controls ----------- +# +# A green suite is not evidence that a fix works — it is evidence that nothing +# currently measured disagrees with it, and a control can pass for the wrong +# reason. So the defect is put back, the same two scenarios are re-run against +# it, and the kills are asserted by name, printing both verdicts so a failure +# says which half moved. +# +# TWO mutants, because #43 has two halves and they are not equally visible: +# +# A the decision only — judge the PROBE host instead of the host of the URL +# about to be fetched. This is the defect the issue names. +# B A, plus removal of the pin-belongs-to-this-host assertion. That is the +# pre-fix code verbatim: it had no pin assertion for the wrong host to hit. +# +# B exists because of a measurement, and the measurement is the interesting part. +# Under A, the direction-two scenario is refused by the pin assertion BEFORE the +# wrong host can be fetched, so it makes the same number of requests and returns +# the same verdict as the shipped code — control 35 cannot see A at all. That is +# not a hole in control 35: it is a second guard holding the same invariant, in +# the same shape as the assertion at the end of resolve_public. But a kill that +# comes from a DIFFERENT guard is not a kill of this control, so B removes that +# guard as well and control 35 is asserted to die on the defect itself. + +echo +echo "--- mutation control: put #43 back and watch the controls die" + +MUTANT_A="$WORK/mutant-decision.bash" +MUTANT_B="$WORK/mutant-prefix.bash" +sed 's#^ judged="\$(host_of_url "\$final")" || return 1$# judged="$probe"#' \ + "$WORK/sut.bash" > "$MUTANT_A" +sed -e 's#^ judged="\$(host_of_url "\$final")" || return 1$# judged="$probe"#' \ + -e 's#^ \[\[ "${pin_host,,}" == "\$judged" \]\] || return 1$# true#' \ + "$WORK/sut.bash" > "$MUTANT_B" + +# An extraction or a sed that matched nothing would leave a file identical to the +# shipped code, and every kill below would then be a claim about a mutant that +# was never built — the absence-shaped failure this suite exists to catch. (A +# mutation that did not apply has already happened here once, during this +# change: the first version of the sed was anchored at the end of a line that +# continues with `|| return 1`, matched nothing, and the suite said so.) +assert_mutant() { + if cmp -s "$WORK/sut.bash" "$1"; then + bad "$2 differs from the shipped code" \ + "the mutation did not apply, so the kills below would be vacuous" + elif ! bash -n "$1" 2>"$WORK/mutant.syntax"; then + bad "$2 differs from the shipped code" \ + "does not parse: $(cat "$WORK/mutant.syntax")" + else + ok "$2 differs from the shipped code and parses" + fi +} +assert_mutant "$MUTANT_A" "mutant A (decision only)" +assert_mutant "$MUTANT_B" "mutant B (decision + no pin assertion)" +grep -q '^ judged="\$probe"$' "$MUTANT_A" \ + || bad "mutant A contains the reverted line" "the probe host is not what it judges" +grep -q '^ judged="\$probe"$' "$MUTANT_B" \ + || bad "mutant B contains the reverted line" "the probe host is not what it judges" + +# The shipped code and each mutant are given the SAME scenario, in the same +# subshell, through the same stub; only the sourced file differs. +shipped_a="$(verdict "$WORK/sut.bash" "https://www.example.com/" to_webmail m_webmail)" +shipped_b="$(verdict "$WORK/sut.bash" "https://webmail.example.com/" to_www m_webmail)" +mutant_a="$(verdict "$MUTANT_A" "https://www.example.com/" to_webmail m_webmail)" +mutant_b="$(verdict "$MUTANT_B" "https://webmail.example.com/" to_www m_webmail)" +mutant_a_b="$(verdict "$MUTANT_A" "https://webmail.example.com/" to_www m_webmail)" + +# 46. Direction one. Mutant A decides on www., finds no login prefix, and never +# looks at webmail. — two requests, no finding. Control 34 goes red, and it +# goes red for the right reason: the credential surface was not fetched. +if [[ "$shipped_a" == "rc=0 state=finding calls=3" && "$mutant_a" != "$shipped_a" ]]; then + ok "control 34 kills mutant A (shipped: $shipped_a; mutant: $mutant_a)" +else + bad "control 34 kills mutant A" \ + "shipped: '$shipped_a' mutant: '$mutant_a' — the control does not measure the fix" +fi + +# 47. Direction two, against mutant B. The pre-fix code decides on webmail., +# matches, fetches the homepage with the caller's pin — and reports a page it +# was not asked about. Control 35 goes red. +if [[ "$shipped_b" == "rc=1 state=none calls=2" && "$mutant_b" != "$shipped_b" ]]; then + ok "control 35 kills mutant B (shipped: $shipped_b; mutant: $mutant_b)" +else + bad "control 35 kills mutant B" \ + "shipped: '$shipped_b' mutant: '$mutant_b' — the control does not measure the fix" +fi + +# 48. And the reason mutant A is invisible to control 35 is asserted, not +# assumed: mutant A's direction-two verdict must EQUAL the shipped one, with +# no fetch either. If that ever stops being true, control 35 is measuring +# something different from what its comment claims. +if [[ "$mutant_a_b" == "$shipped_b" ]]; then + ok "mutant A is masked on direction two by the pin assertion, as documented" +else + bad "mutant A is masked on direction two by the pin assertion" \ + "shipped: '$shipped_b' mutant A: '$mutant_a_b' — the comment is no longer true" +fi + +# 49. Neither mutant may be killed for some OTHER reason. Both runs completed a +# redirect walk and reached the decision, so a red control 34/35 cannot be +# explained by the mutant crashing, refusing to walk, or dying before the +# decision. Stated separately because "the control failed" and "the control +# failed for the reason it claims" are different claims, and only the second +# is a kill. +crashed=0 +for v in "$mutant_a" "$mutant_a_b" "$mutant_b"; do + [[ "$v" == rc=*" state="*" calls="* ]] || crashed=$((crashed + 1)) +done +if [[ "$crashed" -eq 0 ]]; then + ok "every mutant run reached the login decision (no crash, no abort)" +else + bad "every mutant run reached the login decision" \ + "$crashed run(s) produced no verdict — a crash would fake a kill" +fi + printf 'passed: %s failed: %s\n' "$PASS" "$FAIL" if [[ "$FAIL" -eq 0 ]]; then echo "RESULT: the follower follows, and refuses what it must."