diff --git a/docs/remote-access.md b/docs/remote-access.md index 1e81fa1..89d1c3f 100644 --- a/docs/remote-access.md +++ b/docs/remote-access.md @@ -106,3 +106,32 @@ One requirement remains, and it is not a formality: than assumed from the loopback behaviour. That needs a real deployed origin, so it cannot be closed from a checkout — and until it is, an installed console reached through a tunnel is untested, not merely unsupported. + +### Verifying it, once you have an origin + +``` +scripts/verify-tunnel-context.sh https://keel.example.com +``` + +The token is the one `keel serve` printed in its URL. The script has two parts and **only the +first one is automated**, which is the honest shape of this requirement rather than a limitation +of the script: + +**Part 1 — what HTTP can answer.** That the shell is served over the external origin; that the +token is exchanged for a `SameSite=Strict` session cookie there; that `manifest.webmanifest` and +`sw.js` are actually served; that responses carry `nosniff`; and — the one that matters most — +that **a spoofed `Host:` is still refused**. `--external-host` teaches the server one name, and a +deployment where `Host: evil.example` is answered is one where the allowlist has been widened +until it stopped being one. Exits non-zero on any failure. + +It refuses a plain `http://` origin outright. Every check in it is about what HTTPS provides, and +run against `http://` it would report a pass for a page that is not a secure context. + +**Part 2 — what only a browser can answer.** `window.isSecureContext`, whether the service worker +registered and with what scope, whether the cache is genuinely per-origin, whether the manifest +installs, and how the installed console behaves when the tunnel dies. The script prints these as a +checklist and does not pretend its exit code covers them: a script claiming +`window.isSecureContext` from `curl` would be reporting something it never looked at. + +**#648 closes when part 2 has been done on a device and recorded on the issue.** Part 1 passing is +not sufficient, and no amount of scripting will make it so. diff --git a/scripts/verify-tunnel-context.sh b/scripts/verify-tunnel-context.sh new file mode 100755 index 0000000..04f937d --- /dev/null +++ b/scripts/verify-tunnel-context.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# Verify a keel console reached through a tunnel, before trusting it (#648). +# +# `keel serve` binds loopback, and `http://127.0.0.1` is a secure context BY SPECIFICATION -- which +# is why the service worker and the manifest work today without anyone arranging it. Over an +# external origin that property comes from HTTPS instead, so it has to be RE-VERIFIED there rather +# than assumed from the loopback behaviour. That is the last open item on #648 and the reason +# `docs/remote-access.md` still says do not expose the console yet. +# +# ── WHAT THIS SCRIPT CAN AND CANNOT ANSWER ─────────────────────────────────────────────────────── +# +# It checks everything that is visible over HTTP: the scheme, the headers, whether the Host +# allowlist still refuses a name it was not told to expect, and whether the shell and the two PWA +# assets are actually served over the external origin. +# +# It CANNOT answer `window.isSecureContext`, whether the service worker registered, or whether the +# install prompt appeared. Those are browser state, and a script that claimed them from curl would +# be reporting something it never looked at. Part 2 below is a short browser checklist for exactly +# those, and it is not optional -- it is the half of #648 that a shell cannot close. +# +# Usage: scripts/verify-tunnel-context.sh https://keel.example.com +# +# Exit status: 0 when every HTTP-visible check passed, 1 otherwise. The browser checklist is +# printed, never assumed. + +set -euo pipefail + +ORIGIN="${1:-}" +TOKEN="${2:-}" + +if [ -z "$ORIGIN" ] || [ -z "$TOKEN" ]; then + echo "usage: $0 " >&2 + echo "the token is the one keel serve printed in its URL" >&2 + exit 2 +fi + +case "$ORIGIN" in + https://*) ;; + *) + # Refused rather than warned about. Every check below is about what HTTPS provides, and + # running them against http:// would report a pass for a page that is not a secure context. + echo "REFUSED: $ORIGIN is not https. A tunnel origin without TLS gives the console none of" >&2 + echo "the properties this script exists to verify, and 127.0.0.1's exemption does not apply" >&2 + echo "to it." >&2 + exit 2 + ;; +esac + +HOST="${ORIGIN#https://}" +HOST="${HOST%%/*}" +FAILED=0 + +pass() { printf ' ok %s\n' "$1"; } +fail() { printf ' FAIL %s\n' "$1"; FAILED=1; } + +# One cookie jar for the run: the first request exchanges the token for the session cookie, and +# everything after it has to work the way a browser would -- on the cookie, not on the token. +JAR="$(mktemp)" +trap 'rm -f "$JAR"' EXIT + +fetch() { + # $1 path, $2.. extra curl args. Prints "status\n". + local path="$1" + shift + curl --silent --show-error --location --max-time 20 \ + --cookie "$JAR" --cookie-jar "$JAR" \ + --write-out '%{http_code}\n' --dump-header - --output /dev/null \ + "$@" "$ORIGIN$path" +} + +echo "keel tunnel verification -- $ORIGIN" +echo +echo "part 1: what HTTP can answer" + +# -- the session, over the external origin -------------------------------------------------------- +BOOT="$(fetch "/?token=$TOKEN" || true)" +if printf '%s' "$BOOT" | grep -qE '^(200|204)$|^HTTP/[0-9.]+ 200'; then + pass "the shell is served over the external origin" +else + fail "the shell did not answer 200 over the external origin" +fi + +if grep -qi 'keel_session' "$JAR"; then + pass "the token was exchanged for a session cookie" +else + fail "no session cookie was set -- the token was rejected, or the Host is not allowlisted" +fi + +# `SameSite=Strict` and `Secure` come off the Set-Cookie header rather than the jar, because +# curl's jar format keeps neither. +COOKIE_HEADER="$(printf '%s' "$BOOT" | grep -i '^set-cookie:' || true)" +if printf '%s' "$COOKIE_HEADER" | grep -qi 'samesite=strict'; then + pass "the session cookie is SameSite=Strict" +else + fail "the session cookie is not SameSite=Strict" +fi + +# -- the rebinding defence, still refusing --------------------------------------------------------- +# +# THE CHECK THAT MATTERS MOST HERE. `--external-host` teaches the server one name; everything else +# must still be refused, including a name that resolves to the same address. A tunnel deployment +# where this passes is one where the allowlist has been widened until it stops being one. +SPOOFED="$(curl --silent --show-error --max-time 20 --output /dev/null \ + --write-out '%{http_code}' --header "Host: evil.example" "$ORIGIN/api/config" || true)" +if [ "$SPOOFED" = "403" ] || [ "$SPOOFED" = "400" ] || [ "$SPOOFED" = "421" ]; then + pass "a spoofed Host is still refused ($SPOOFED)" +else + # A tunnel may rewrite Host before keel sees it, in which case this proves nothing about keel + # and the operator has to check the edge instead. Said plainly rather than scored as a pass. + fail "a spoofed Host answered $SPOOFED -- either the allowlist is too wide, or the tunnel" + printf ' rewrites Host before keel sees it (check the edge, not the engine)\n' +fi + +# -- the PWA assets, over the external origin ------------------------------------------------------ +for asset in /manifest.webmanifest /sw.js; do + STATUS="$(fetch "$asset" | tail -n 1 || true)" + if [ "$STATUS" = "200" ]; then + pass "$asset is served (200)" + else + fail "$asset answered $STATUS" + fi +done + +NOSNIFF="$(fetch /sw.js | grep -ci 'x-content-type-options: nosniff' || true)" +if [ "$NOSNIFF" != "0" ]; then + pass "responses carry X-Content-Type-Options: nosniff" +else + fail "no nosniff header -- a sniffing browser decides what the service worker IS" +fi + +echo +echo "part 2: what only a browser can answer -- run these on the device, on $ORIGIN" +cat < Application > Service Workers: one worker, "activated and is running", + with a scope of $ORIGIN/ + [ ] devtools > Application > Cache Storage: the cache name carries THIS build's version, and + holds no entry from a loopback origin (caches are per-origin -- a shared entry means the + origin is not what you think it is) + [ ] devtools > Application > Manifest: no errors, icons resolve, and the browser offers to + install + [ ] install it, then kill the tunnel: the installed console opens and reports the engine as + unreachable, rather than showing a blank page or stale figures without saying so + [ ] devtools > Application > Cookies: the session cookie is Secure, HttpOnly and SameSite=Strict +CHECKLIST + +echo +if [ "$FAILED" -eq 0 ]; then + echo "part 1 passed. #648 closes when part 2 is done ON A DEVICE and recorded on the issue --" + echo "a shell cannot see any of it." +else + echo "part 1 FAILED. Do not expose the console until the failures above are understood." +fi +exit "$FAILED" diff --git a/tests/test_security_policy.py b/tests/test_security_policy.py index ecf87d6..eb7cbfc 100644 --- a/tests/test_security_policy.py +++ b/tests/test_security_policy.py @@ -90,3 +90,59 @@ def test_out_of_scope_reports_are_named(): "SECURITY.md must name strategy performance and market losses as out of scope" ) assert "key" in text, "SECURITY.md must name a user's own key handling as out of scope" + + +# -- the tunnel verification script (#648) --------------------------------------------------------- + + +def _verify_script() -> str: + from pathlib import Path + + root = Path(__file__).resolve().parent.parent + return (root / "scripts" / "verify-tunnel-context.sh").read_text(encoding="utf-8") + + +def test_the_tunnel_script_refuses_a_plain_http_origin() -> None: + """Every check in it is about what HTTPS provides. Run against `http://`, it would report a + pass for a page that is not a secure context -- and `127.0.0.1`'s exemption does not extend to + a tunnel's hostname.""" + source = _verify_script() + assert "REFUSED" in source + assert "is not https" in source + + +def test_the_tunnel_script_does_not_claim_what_only_a_browser_knows() -> None: + """`window.isSecureContext`, service-worker registration and the install prompt are browser + state. A script asserting them from curl would be reporting something it never looked at, so + they live in a printed checklist that the operator has to actually do. + + This is the half of #648 a shell cannot close, and the script says so rather than implying its + exit code covers it. + """ + source = _verify_script() + assert "part 2" in source + assert "isSecureContext" in source + + # The EXECUTABLE region only -- between the shebang block's explanation and the checklist. + # The header comment names `isSecureContext` precisely to say it cannot be answered here, and + # a scan that could not tell an explanation from a claim would forbid the file explaining + # itself. + body = source.split("set -euo pipefail", 1)[1].split("part 2", 1)[0] + assert "isSecureContext" not in body, "part 1 claims a property only a browser can report" + assert "serviceWorker" not in body + + +def test_the_tunnel_script_still_expects_a_spoofed_host_to_be_refused() -> None: + """The check that matters most. `--external-host` teaches the server ONE name; a deployment + where a spoofed Host is answered is one where the allowlist has been widened until it stopped + being one.""" + source = _verify_script() + assert "Host: evil.example" in source + assert "the allowlist is too wide" in source + + +def test_the_tunnel_script_exits_nonzero_when_a_check_fails() -> None: + """An operator wiring this into anything needs the status to mean something.""" + source = _verify_script() + assert 'exit "$FAILED"' in source + assert "set -euo pipefail" in source diff --git a/tests/test_wrapper_shellcheck.py b/tests/test_wrapper_shellcheck.py index 3cc7f5b..2f2aa61 100644 --- a/tests/test_wrapper_shellcheck.py +++ b/tests/test_wrapper_shellcheck.py @@ -22,6 +22,10 @@ REPO_ROOT = Path(__file__).resolve().parent.parent WRAPPERS = [ + # #648's tunnel verification. It is the script an operator runs before trusting a console + # reached from another device, so a shell bug in it is a bug in the one check standing between + # them and an exposed financial surface. + REPO_ROOT / "scripts" / "verify-tunnel-context.sh", REPO_ROOT / "keel-live-run.sh", REPO_ROOT / "paperforward-run.sh", REPO_ROOT / "paper-hourly-run.sh",