Audit round 6 (mobile + --lan): access key replaces trust-by-allowlist

Five blind reviewers over 2936d21; 13 confirmed fixes. The design
change: the Host allowlist was CSRF armor being asked to do access
control. --lan now mints a per-run access key — the printed URLs carry
?k=..., the first visit sets a cookie — required on EVERY request,
reads included (shelf photos and pipeline state are private). That
closes DNS-rebinding read exfiltration (GETs were exempt from the old
guard), closes any-LAN-device mutations via a forged localhost Host,
and frees phones from allowlist accuracy — multi-interface machines,
DHCP renewals, and failed IP discovery no longer strand writes. A
foreign Origin is still refused even with the key.

Guard hardening: Host parsed via url.hostname (ports, IPv6 brackets,
case) instead of a manual split; refusals now echo one stderr line
(they were invisible at log_level=warning) and the LAN 403 names the
remedy; startup warns when no LAN IP could be determined instead of
printing hostname-only URLs as if verified.

Silent failures: the queue page no longer freezes blank forever when a
render throws (LAST was recorded before render; one malformed CSV cell
would blank all three ledgers and blame the network) — all three
change-detection pages record LAST only after a successful render, and
the queue null-guards source_photos.

Mobile: touch-size the review/ticket/merge buttons the finger-sized
rule lost to on specificity. Style: the meta-cell builder is one shared
metaLine() helper; the Help page no longer claims localhost-only;
dead -webkit prefix dropped; --lan help text in house style.

Tests: token gating (reads and writes, cookie handoff, foreign-Origin
refusal), the Origin-present + Host-with-port path every real browser
mutation takes (was fully uncovered), run_web_review's lan branch via
monkeypatched uvicorn, and a lan_hosts test that actually pins the
lowercase/non-empty/v4-only invariants the guard depends on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
Eric Wagoner
2026-08-03 16:12:01 -04:00
co-authored by Claude Fable 5
parent 2936d21f0a
commit d4e619611a
10 changed files with 175 additions and 40 deletions
+81 -3
View File
@@ -894,9 +894,87 @@ def test_lan_allowed_hosts_admit_network_but_not_strangers(tmp_path):
)
def test_lan_hosts_reports_this_machine(tmp_path):
def test_lan_hosts_are_lowercase_nonempty_and_v4_only(monkeypatch):
import socket as socket_mod
from bggpipe.webreview import lan_hosts
monkeypatch.setattr(socket_mod, "gethostname", lambda: "Erics-Mac.Example.COM")
monkeypatch.setattr(
socket_mod,
"getaddrinfo",
lambda *a, **k: (_ for _ in ()).throw(OSError("no dns")),
)
hosts = lan_hosts()
assert hosts # at least the hostname
assert all(h == h.lower() or "." in h for h in hosts)
assert "erics-mac.example.com" in hosts
assert "erics-mac.local" in hosts
assert "" not in hosts # an empty entry would admit Host-less mutations
assert all(":" not in h for h in hosts) # no v6 forms, no ports
def test_lan_token_gates_every_request(tmp_path):
cfg = make_cfg(tmp_path)
app = create_app(cfg, client=unauthorized_client(tmp_path), lan_token="sekret")
phone = TestClient(app, base_url="http://192.168.1.99:8377")
# reads are gated too: shelf photos and state are private
assert phone.get("/api/state").status_code == 403
assert phone.get("/api/state?k=wrong").status_code == 403
first = phone.get("/titles?k=sekret")
assert first.status_code == 200
assert "bggpipe_key" in first.cookies # the URL key becomes a cookie
# cookie carries the session: mutations work from ANY host the phone
# used (no allowlist dependence — DHCP/multi-interface safe), with a
# same-origin Origin header and a port, like a real phone browser
res = phone.post(
"/api/edit-title",
json={"title_raw": "Citadels", "source_photos": "shelf.jpg", "confirm": True},
headers={"origin": "http://192.168.1.99:8377"},
)
assert res.status_code == 200
# a foreign Origin is still refused even with the key
assert (
phone.post(
"/api/dismiss",
json={"photo": "shelf.jpg"},
headers={"origin": "http://evil.example"},
).status_code
== 403
)
def test_localhost_mutations_pass_with_origin_and_port(tmp_path):
# the path every real browser takes: Origin present + port in Host —
# regressing the header parsing must fail loudly here
cfg = make_cfg(tmp_path)
app = create_app(cfg, client=unauthorized_client(tmp_path))
web = TestClient(app, base_url="http://localhost:8377")
res = web.post(
"/api/edit-title",
json={"title_raw": "Citadels", "source_photos": "shelf.jpg", "confirm": True},
headers={"origin": "http://localhost:8377"},
)
assert res.status_code == 200
assert (
TestClient(app, base_url="http://attacker.example:8377")
.post(
"/api/edit-title",
json={"title_raw": "Citadels", "source_photos": "shelf.jpg"},
)
.status_code
== 403
)
def test_run_web_review_lan_branch_binds_and_warns(tmp_path, monkeypatch, capsys):
import bggpipe.webreview as wr
captured = {}
monkeypatch.setattr("uvicorn.run", lambda app, **kw: captured.update(kw, app=app))
cfg = make_cfg(tmp_path)
wr.run_web_review(cfg, port=9999, lan=True)
out = capsys.readouterr().out
assert captured["host"] == "0.0.0.0"
assert "?k=" in out # every printed URL carries the access key
assert "Use only on a network you trust" in out
wr.run_web_review(cfg, port=9999, lan=False)
assert captured["host"] == "127.0.0.1"