docs: an access control the browser cannot satisfy is not a control
Copilot's second pass, confirmed: the basic_auth recipe from the previous commit CANNOT WORK. web/app.js opens `new WebSocket(WS_URL)` -- one argument, a URL -- and the browser WebSocket API cannot set request headers, while a WebSocket handshake shows no login prompt. So Caddy basic_auth in front of it is a 401 the page can neither answer nor explain. That is worse than the unauthenticated recipe it replaced, because it READS AS PROTECTION. The first round's lesson was that documentation instructing an action carries the action's consequences; this round is sharper and is the one worth keeping: A RECIPE IS CODE, and it needs the same "would this actually run" test the Rust gets. Neither my review pass nor the fix asked it of a Caddyfile. Note it is the same fact that already killed native wss:// in this same document -- a WS handshake has no interstitial, which is why a self-signed cert is unusable. The argument was two sections away and did not transfer. So the section now sorts controls by whether a browser can satisfy them at all, since that is the property that decides it: * basic auth and client-cert mTLS: named as NOT working, with the reason. * IP allow-list: the worked example, via Caddy's documented remote_ip/abort. Needs no credential from the page. * private network (Tailscale/WireGuard): named; needs no credential either. * cloudflared + Cloudflare Access: named and explicitly NOT written out as a recipe, because it turns on a session cookie from a prior interactive login and third-party-cookie rules, which cannot be verified without a browser. Documenting a flow I cannot test is the trap improvise-hwpi named. `ngrok http --basic-auth` carried the identical bug and is corrected to --cidr-allow, which is the same address check. Says plainly that a URL-borne token is what would make this work browser-side -- a query parameter being the one credential `new WebSocket(url)` can carry -- and that the server has no such capability: improvise-kpnr (ID read back). And states that the Caddyfile has not been executed here, on the same terms as the existing browser-JS caveat. 1184 passing, unchanged; no code touched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -246,12 +246,36 @@ of it removes exactly that protection*: the proxy is public, so the endpoint is
|
||||
public, and `--bind 127.0.0.1` protects the port while doing nothing about the
|
||||
proxy. TLS gets you confidentiality, not access control.
|
||||
|
||||
So put the access control in the proxy, and treat it as required rather than
|
||||
optional — HTTP basic auth, mTLS, an IP allow-list, or a tunnel with its own
|
||||
authentication (`cloudflared` can enforce Cloudflare Access; a bare
|
||||
`--url` tunnel enforces nothing and is a public URL). Caddy's `basic_auth`
|
||||
directive is the smallest version. Share only documents you would be willing
|
||||
to let an anonymous visitor edit, and take the tunnel down when you are done.
|
||||
**And the control has to be one a browser can actually satisfy**, which rules
|
||||
out most of the obvious ones. The page opens the socket as
|
||||
`new WebSocket(url)` — one argument, a URL. The browser WebSocket API **cannot
|
||||
set request headers**, and a WebSocket handshake shows **no login prompt**, so
|
||||
HTTP basic auth in front of it is not a locked door, it is a **401 the page
|
||||
cannot answer and cannot explain** — the same reason a self-signed certificate
|
||||
is unusable here (see "Why not native `wss://`"). Client-certificate mTLS has
|
||||
the same problem: the selection UI browsers show for a top-level navigation is
|
||||
not something a cross-origin socket handshake can rely on.
|
||||
|
||||
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.
|
||||
- **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). This one
|
||||
works by a session **cookie** set after an interactive login on the proxy's
|
||||
own hostname, so it needs the user to visit that hostname first, and it is
|
||||
subject to the browser's third-party-cookie rules. Plausible and *not
|
||||
verified here* — there is no browser in this repository — so it is named, not
|
||||
written out as a recipe.
|
||||
|
||||
What would make this genuinely easy is a **token in the URL**, because a query
|
||||
parameter is the one credential `new WebSocket(url)` can carry. The server has
|
||||
no such capability today, and whether it should grow one — or whether "auth is
|
||||
the proxy's job" becomes a stated design position — is **improvise-kpnr**.
|
||||
|
||||
Until then: share only documents you would be willing to let anyone on the
|
||||
other side of that control edit, and take the tunnel down when you are done.
|
||||
|
||||
A worked example with [Caddy](https://caddyserver.com/), which obtains and
|
||||
renews the certificate itself. Point a DNS name at the host, then:
|
||||
@@ -259,11 +283,12 @@ renews the certificate itself. Point a DNS name at the host, then:
|
||||
```caddyfile
|
||||
# Caddyfile
|
||||
improvise.example.com {
|
||||
# NOT optional — see above. Without this the document is world-writable.
|
||||
# Generate the hash with: caddy hash-password
|
||||
basic_auth {
|
||||
editor $2a$14$...replace-with-your-own-hash...
|
||||
}
|
||||
# NOT optional — see above. Without it the document is world-writable.
|
||||
# An allow-list, not a password: the browser cannot send a credential on a
|
||||
# WebSocket handshake, so this is checked against the client's address.
|
||||
@denied not remote_ip 203.0.113.4 198.51.100.0/24
|
||||
abort @denied
|
||||
|
||||
reverse_proxy 127.0.0.1:8787
|
||||
}
|
||||
```
|
||||
@@ -273,6 +298,13 @@ improvise-ws-server bank-info.improv --port 8787 # still plain ws, still loopb
|
||||
caddy run # terminates TLS, proxies to it
|
||||
```
|
||||
|
||||
On the same terms as the browser caveat below: **this configuration has not
|
||||
been executed in this repository** — there is no Caddy and no browser here. It
|
||||
is the form Caddy documents for `remote_ip`, and the reasoning about what a
|
||||
browser can satisfy is checked against `web/app.js`, which opens
|
||||
`new WebSocket(url)` and can therefore send no header. Verify the deny actually
|
||||
denies before trusting it with a document.
|
||||
|
||||
Then, from <https://fiddlerwoaroof.github.io/improvise/>, reload with
|
||||
`?ws=improvise.example.com`. Caddy preserves the request path, so the
|
||||
multi-document form works too: serve several files and name one with
|
||||
@@ -287,9 +319,12 @@ 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, so add the tunnel's own auth (`ngrok http --basic-auth`,
|
||||
or a named Cloudflare tunnel behind Access) rather than relying on the URL
|
||||
being unguessable.
|
||||
anything by default — and note that `ngrok http --basic-auth` does **not** fix
|
||||
that here, for the reason above: the page cannot answer a basic-auth challenge
|
||||
on a socket handshake. `ngrok http --cidr-allow` is the form that works, being
|
||||
the same address check as the Caddy example. An unguessable URL is not a
|
||||
control either. If you cannot put an address check in front of it, do not
|
||||
expose it.
|
||||
|
||||
Note the server binds `127.0.0.1` by default and `--bind` is the only way to
|
||||
widen that. Behind a proxy on the same host you should *not* widen it: the
|
||||
|
||||
Reference in New Issue
Block a user