--lan: the desktop stays keyless; phone URLs drop loopback

Dogfooding caught both: the auto-opened desktop tab (and every old
polling tab) 403'd for lack of the key, and the "from your phone" list
offered 127.0.0.1. Loopback CLIENT connections now skip the key — a
network peer cannot arrive with a loopback client address — and fall
through to the same Host/Origin guard as the localhost default, so
rebinding pages (foreign Host) and cross-origin POSTs (foreign Origin)
from a local browser stay blocked. Loopback addresses are filtered out
of the printed phone URLs.

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:16:55 -04:00
co-authored by Claude Fable 5
parent 429ceb1a21
commit eb2e841d27
2 changed files with 57 additions and 3 deletions
+43
View File
@@ -942,6 +942,42 @@ def test_lan_token_gates_every_request(tmp_path):
)
def test_lan_loopback_stays_keyless_but_guarded(tmp_path):
cfg = make_cfg(tmp_path)
app = create_app(cfg, client=unauthorized_client(tmp_path), lan_token="sekret")
local = TestClient(
app, base_url="http://127.0.0.1:8377", client=("127.0.0.1", 50000)
)
# the desktop browser (old tabs, the auto-opened one) needs no key
assert local.get("/api/pipeline").status_code == 200
assert (
local.post(
"/api/edit-title",
json={
"title_raw": "Citadels",
"source_photos": "shelf.jpg",
"confirm": True,
},
headers={"origin": "http://127.0.0.1:8377"},
).status_code
== 200
)
# but loopback keylessness never extends to foreign Hosts (rebinding)
# or foreign Origins (classic CSRF from a local browser)
rebound = TestClient(
app, base_url="http://evil.example:8377", client=("127.0.0.1", 50000)
)
assert rebound.get("/api/state").status_code == 403
assert (
local.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
@@ -971,10 +1007,17 @@ def test_run_web_review_lan_branch_binds_and_warns(tmp_path, monkeypatch, capsys
captured = {}
monkeypatch.setattr("uvicorn.run", lambda app, **kw: captured.update(kw, app=app))
cfg = make_cfg(tmp_path)
monkeypatch.setattr(
wr, "lan_hosts", lambda: {"127.0.0.1", "192.168.1.5", "erics-mac.local"}
)
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 "http://192.168.1.5:9999/?k=" in out
# no phone can reach loopback: it never appears in the phone list
assert "phone" not in out.split("http://127.0.0.1:9999")[-1].split("\n")[0]
assert out.count("http://127.0.0.1:9999") == 1 # the desktop line only
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"