fix: two Origin headers state none a policy can answer for

Review of the previous commit. `HeaderMap::get` is first-wins, and the
deployment README recommends puts a reverse proxy in front of the socket — one
that APPENDS `Origin` rather than setting it leaves two, and answering from the
first lets the second be anything at all. No browser sends two, so two is not a
state any policy can answer for and the handshake is refused, exactly as an
`Origin` that is not text already was.

Guarded by two_origins_state_none_this_policy_can_answer_for, proved by
restoring the first-wins read, which fails that test alone across the
workspace. The test also asserts the allowed origin BY ITSELF is still allowed,
so the refusal is demonstrably the second header rather than the first.

Second review finding: a_handshake_from_a_foreign_origin_is_refused matched
"403" in a Display string, which cannot tell a refused upgrade from a dropped
connection. It now matches Error::Http and asserts the status. Proved by
changing forbidden() to UNAUTHORIZED, which fails it and
a_refusal_is_a_forbidden_response.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
This commit is contained in:
Edward Langley
2026-09-20 21:21:49 -07:00
parent ee19afbdd8
commit cead3210e5
4 changed files with 66 additions and 13 deletions
+8 -3
View File
@@ -293,9 +293,14 @@ the repo's own tests and every scripted client while buying nothing, since an
attacker who can run a non-browser client already has direct network access,
which is the question `--bind` and a proxy answer. The cost is stated rather
than hidden: **a proxy that strips `Origin` turns this check off**, so the
proxy's own Origin allow-list stays in the recipe as defence in depth. An
`Origin` header that is not valid text is refused rather than read as absent —
it is not a value any policy could name.
proxy's own Origin allow-list stays in the recipe as defence in depth.
Two shapes state no origin a policy can answer for, and both are refused rather
than read as absent: an `Origin` that is not valid text, and **two `Origin`
headers**. `HeaderMap::get` is first-wins, and the deployment this README
recommends puts a proxy in front of the socket — one that appends rather than
sets the header leaves two, and answering from the first lets the second be
anything at all. No browser sends two.
**What this does not buy is authentication.** It answers "what page is driving
this client", never "who is connecting"; anything that is not a browser page