fix: check Origin at the handshake, so a page cannot drive someone's session

improvise-kc7n. A websocket handshake is exempt from the same-origin policy —
there is no CORS preflight — so any page a user visited could open a socket to
improvise-ws-server from that user's own allowed address and read and edit every
document served. An address check cannot see it: the request genuinely comes
from an allowed client, and only Origin says what is driving it. Loopback is the
least protected case, having no proxy in front of it at all.

`src/origin.rs` is the decision and `transport::admitted` its one caller, which
refuses the upgrade with a 403 rather than accepting and ignoring. The default
allows pages served from loopback — which is the shipped local recipe, page on
`http://localhost:8080` and socket on another loopback port — and refuses
everything else; `--allow-origin` names the deployed page, which reaches the
server through a TLS proxy and is not loopback. An absent Origin is allowed and
decided rather than defaulted: no browser omits it, so absent is a non-browser
client, and refusing those would break this crate's own tests while buying
nothing that --bind does not already buy.

Five edits, five guards, each reverted alone against the named test:

- origin.rs permits() present arm  -> a_foreign_origin_is_refused
- origin.rs permits() None arm     -> a_handshake_with_no_origin_is_accepted
- origin.rs is_loopback()          -> a_handshake_from_a_loopback_page_is_accepted
- transport.rs handshake callback  -> a_handshake_from_a_foreign_origin_is_refused
- main.rs --allow-origin wiring    -> allow_origin_widens_the_policy_and_nothing_else_does

`transport::serve` takes the policy rather than defaulting one, so a host cannot
forget to pass it. The argument is context/code/clients.md §Origin and the
evidence context/test-evidence/clients.md; README no longer says the server
never looks, and the proxy's Origin allow-list is now defence in depth.

This is not authentication — it answers what page is driving a client, never who
is connecting. improvise-kpnr is untouched and still open.

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:20:18 -07:00
parent 2afbf37b67
commit d100639348
8 changed files with 517 additions and 42 deletions
+32 -31
View File
@@ -236,26 +236,19 @@ optimising for: `http://localhost` is not a secure-context problem, so nothing
below is needed to develop, to demo locally, or to share a TUI-hosted document
with another tab on the same machine.
One caveat even there, for the reason spelled out below: a WebSocket is exempt
from the same-origin policy, and **nothing in the server checks `Origin`** — so
a page you did not open can connect to `ws://127.0.0.1:8787` and read or edit
the document while the server runs.
The server checks `Origin` on the handshake, which is what makes that safe. A
WebSocket is exempt from the same-origin policy — there is no CORS preflight —
so without the check any page you visited could connect to `ws://127.0.0.1:8787`
and read or edit the document while the server runs. **Pages served from
loopback are allowed by default**, which is exactly the recipe above; every
other page is refused unless you name it with `--allow-origin`. A client that
sends no `Origin` at all — curl, a script, a native client — is allowed, because
no browser omits it and refusing them would answer a question `--bind` already
answers.
How much the *browser* mitigates that is in flux and should not be leaned on.
Chromium has been rolling out **Local Network Access**, which puts requests
from a public-network origin to a loopback or private-network address behind a
permission prompt; per Chrome's own documentation WebSockets were **not yet**
covered when the prompt began shipping, with coverage planned, so whether a
given browser and version gates *this* socket is not something this project can
state — and nothing here can test it: the Playwright harness serves a loopback
origin, so Local Network Access never engages in it. Two gaps are structural
rather than version-dependent: it is a Chromium feature, so other browsers need
not behave the same; and it only engages for a **public** origin, so the
locally-served page this section is about is outside it entirely.
Treat it, then, as tolerable for a local session you start and stop, and as a
reason not to leave one running unattended. The durable fix is the server
checking `Origin` itself, which it does not yet do.
This is not authentication (see the next section) and it does not depend on the
browser: whatever Chromium's **Local Network Access** work ends up covering, the
handshake is refused here rather than at the browser's discretion.
### Sharing a TUI-hosted document with a remote browser
@@ -309,9 +302,13 @@ any hostile page an allowed user visits can open a socket to your endpoint
*from that user's own allowed address*, and read and write the document. An
address check cannot see that, because by then the request genuinely does come
from an allowed client. Only the **`Origin` header** distinguishes them, and
`improvise-ws-server` **never looks at it** — it accepts every handshake. So a
proxy needs an Origin allow-list **in addition to**, never instead of,
whichever control below you pick. The worked example carries both.
`improvise-ws-server` checks it: pages served from loopback are allowed, and
anything else must be named with `--allow-origin`. **Deploying behind a proxy
therefore needs the flag** — the deployed page's origin is
`https://fiddlerwoaroof.github.io`, which is not loopback. Keep the Origin
allow-list in the proxy as well: it is defence in depth now rather than the only
defence, and it is what covers a proxy configuration that strips or rewrites the
header before the server can see it.
So the honest controls are the ones that need **no credential from the page**:
@@ -350,11 +347,11 @@ improvise.example.com {
@denied not remote_ip 203.0.113.4 198.51.100.0/24
abort @denied
# ALSO not optional, and it is NOT redundant with the address check.
# A WebSocket is exempt from the same-origin policy, so any hostile page
# an allowed user visits can open this socket FROM THAT ALLOWED ADDRESS
# and read and edit the document. The address check cannot see that; only
# the Origin can. Name the exact origins the page is served from.
# Defence in depth, and NOT redundant with the address check. A WebSocket
# is exempt from the same-origin policy, so any hostile page an allowed
# user visits can open this socket FROM THAT ALLOWED ADDRESS. The address
# check cannot see that; only the Origin can. The server checks it too —
# this is what covers a proxy that strips or rewrites the header.
@badorigin not header Origin https://fiddlerwoaroof.github.io
abort @badorigin
@@ -363,8 +360,11 @@ improvise.example.com {
```
```sh
improvise-ws-server bank-info.improv --port 8787 # still plain ws, still loopback
caddy run # terminates TLS, proxies to it
# --allow-origin is required here: the deployed page is not a loopback origin,
# so without it the server refuses the handshake the proxy forwards.
improvise-ws-server bank-info.improv --port 8787 \
--allow-origin https://fiddlerwoaroof.github.io # still plain ws, still loopback
caddy run # terminates TLS, proxies to it
```
On the same terms as the browser caveat below: **this configuration has not
@@ -388,8 +388,9 @@ answered as an ordinary request and the socket never opens.
For a one-off share with no DNS name and no certificate of your own,
`cloudflared tunnel --url http://127.0.0.1:8787` or `ngrok http 8787` each
print an https hostname that works the same way — pass the hostname to `?ws=`
without a scheme. Both print a **public** URL and neither authenticates
anything by default, and an unguessable URL is not a control.
without a scheme, and pass the page's own origin to `--allow-origin`, since it
is not loopback. Both print a **public** URL and neither authenticates anything
by default, and an unguessable URL is not a control.
Two things carry over from above. `ngrok http --basic-auth` does **not** help
here — the page cannot answer a basic-auth challenge on a socket handshake —