docs: an address check cannot see a cross-site socket

Copilot's fifth pass, and the first finding in this PR that is a genuine
security hole rather than a wrong sentence. Verified: there is NO reference to
Origin anywhere in crates/improvise-ws-server -- the server accepts every
handshake.

A WebSocket is EXEMPT FROM THE SAME-ORIGIN POLICY; there is no CORS preflight
on a handshake. So any hostile page an allowed user visits can open a socket
to the endpoint FROM THAT USER'S OWN ALLOWED ADDRESS and read and edit the
document. The IP allow-list I recommended last round is precisely the control
that cannot see this, because by then the request really does come from an
allowed client. Only the Origin header separates them.

This is the same shape as the previous three rounds and it is worth naming as
such: I reasoned about WHO MAY REACH the endpoint and not about WHAT MAY
DRIVE the browser that reaches it -- one more conclusion carried without its
reasoning. "Who may connect" is two questions, and the recipe answered one.

So:
 * the prose states the two questions and says an Origin allow-list is needed
   IN ADDITION TO, never instead of, whichever control is chosen;
 * the Caddyfile carries both checks, with the second commented as explicitly
   not redundant with the first;
 * the `header` matcher syntax is checked against caddyserver.com/docs/
   caddyfile/matchers (`header <field> [<value> ...]`, negatable inside a
   named matcher) rather than written from memory -- the round-3 rule;
 * and the LOCAL pairing paragraph is corrected too, because the same fact
   applies there and I had called it unreservedly fine: loopback is not a
   boundary a browser enforces, so any page open in that browser can reach
   ws://127.0.0.1:8787 while the server runs.

The server-side half -- ws-server should check Origin itself, since a proxy is
not present in the local case at all -- is reported for filing rather than
built here. It is a change to a crate this unit does not own.

1193 passing, unchanged; no code touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Edward Langley
2026-09-19 23:08:24 -07:00
parent 1c98aa0929
commit ae199d431a
+27 -1
View File
@@ -221,6 +221,13 @@ 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 loopback is not a boundary a browser enforces
— so while the server is running, **any** page open in that browser can connect
to `ws://127.0.0.1:8787` and read or edit the document. Nothing in the server
checks `Origin`. That is tolerable for a local session you start and stop, and
it is the reason not to leave one running unattended.
### Sharing a TUI-hosted document with a remote browser
Reaching `improvise-ws-server` from the **deployed** https page means
@@ -267,10 +274,21 @@ the page, but the selection UI browsers show is a top-level-navigation
affordance, and whether a cross-origin socket handshake gets it is **not
something verified here**.
**And "who may connect" is two questions, not one.** A WebSocket is **exempt
from the same-origin policy** — there is no CORS preflight on a handshake — so
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.
So the honest controls are the ones that need **no credential from the page**:
- **An IP allow-list in the proxy** — the cheapest, and the worked example
below. It is about where the client is, so the page sends nothing.
below. It is about where the client is, so the page sends nothing. On its
own it answers only the first of the two questions above.
- **A private network** (Tailscale, WireGuard). The peer is only reachable by
machines already on it; again the page sends nothing.
- **An identity-aware tunnel** (`cloudflared` with Cloudflare Access) — the
@@ -303,6 +321,14 @@ 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.
@badorigin not header Origin https://fiddlerwoaroof.github.io
abort @badorigin
reverse_proxy 127.0.0.1:8787
}
```