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:
Edward Langley
2026-09-19 22:49:13 -07:00
parent 16471d7def
commit 5c1fefb4f1
+49 -14
View File
@@ -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