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