From f4108c2314181e9a3dbd26490862abbe928e61bb Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Mon, 7 Sep 2026 07:26:56 -0400 Subject: [PATCH] docs(web): script the half of #648's secure-context check a shell can do, and say which half that is #648's last open item is secure-context re-verification, and `docs/remote-access.md` already records why it cannot be closed from a checkout: `http://127.0.0.1` is a secure context BY SPECIFICATION, so the service worker and manifest work today without anyone arranging it. Over an external origin that property comes from HTTPS instead and has to be re-verified there. This turns "needs a real origin" into a two-minute run once one exists. TWO PARTS, AND THE SPLIT IS THE HONEST SHAPE OF THE REQUIREMENT Part 1 is what HTTP can answer: the shell served over the external origin, the token exchanged for a `SameSite=Strict` cookie there, `manifest.webmanifest` and `sw.js` actually served, `nosniff` present, and -- the one that matters most -- A SPOOFED `Host:` 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. Non-zero exit on any failure. It refuses a plain `http://` origin outright rather than warning: 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 is what only a browser can answer -- `window.isSecureContext`, whether the 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. Printed as a checklist and NOT covered by the exit code. A script claiming `isSecureContext` from `curl` would be reporting something it never looked at, and a test asserts part 1 makes no such claim -- scoped to the executable region, so the header comment may still name the property in order to say it cannot check it. WHAT WAS ALREADY DONE, VERIFIED RATHER THAN ASSUMED Four of #648's five items shipped in #678 and #680. Rather than trust that, all six defences were mutation-tested: skipping the Host check, admitting a non-loopback name on a loopback bind, dropping the port check, accepting a wildcard external host, making the remote session immortal, and removing the wildcard-bind guard. All six killed. The script joins the shellcheck suite, because a shell bug in it is a bug in the one check standing between an operator and an exposed financial surface. #648 does not close here. It closes when part 2 has been done on a device and recorded on the issue. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KZZxmspQXe5qJ9FAsG13s6 --- docs/remote-access.md | 29 ++++++ scripts/verify-tunnel-context.sh | 156 +++++++++++++++++++++++++++++++ tests/test_security_policy.py | 56 +++++++++++ tests/test_wrapper_shellcheck.py | 4 + 4 files changed, 245 insertions(+) create mode 100755 scripts/verify-tunnel-context.sh diff --git a/docs/remote-access.md b/docs/remote-access.md index 1e81fa1b..89d1c3f5 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 00000000..04f937d2 --- /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 ecf87d60..eb7cbfc6 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 3cc7f5b5..2f2aa614 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",