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:
co-authored by
Claude Fable 5
parent
2936d21f0a
commit
d4e619611a
+81
-3
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user