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:
@@ -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 —
|
||||
|
||||
Reference in New Issue
Block a user