docs: ambient credentials versus challenge-response, which is the real rule
Copilot's fourth pass, and it caught this text CONTRADICTING ITSELF three paragraphs apart -- in prose I wrote in a single commit while fixing the previous round's contradiction. The Caddyfile comment said "the browser cannot send a credential on a WebSocket handshake". That is FALSE, and the same section already said so: the Cloudflare Access bullet explains that Access works by a session COOKIE the handshake carries. A WebSocket handshake is an HTTP request; the browser attaches ambient credentials to it automatically. The true rule, which is sharper than the one it replaces and explains both halves instead of one: AMBIENT credentials are carried -- cookies for that host above all, which is why an identity-aware proxy works at all. CHALLENGE-RESPONSE cannot be satisfied -- the page can set no header (there is no Authorization argument to `new WebSocket`) and can answer no prompt (a handshake surfaces no login dialog and fails to script as a bare connection error). So basic auth is still ruled out, for a REASON rather than by an over-broad invariant, and Access is no longer an unexplained exception to it. Two consequences carried through the rest of the section, on the grep discipline the last round earned: * mTLS is no longer asserted to fail. The certificate IS offered at the TLS layer rather than by the page; only the selection UI is doubtful, and that is now marked as not verified here rather than stated. * "a query parameter is the one credential new WebSocket can carry" restated the same over-broad claim in miniature, and becomes "the one place the page can put a credential DELIBERATELY -- everything else it sends is ambient." 1193 passing, unchanged; no code touched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -248,13 +248,24 @@ proxy. TLS gets you confidentiality, not access control.
|
||||
|
||||
**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.
|
||||
`new WebSocket(url)` — one argument, a URL — and the distinction that decides
|
||||
everything here is **ambient credentials versus challenge–response**:
|
||||
|
||||
- The handshake **is** an HTTP request, so the browser attaches what it
|
||||
attaches *automatically* — cookies for that host above all. That is why an
|
||||
identity-aware proxy can work at all.
|
||||
- What the page cannot do is **set a header** (there is no `Authorization`
|
||||
argument to `new WebSocket`) or **answer a prompt** (a handshake surfaces no
|
||||
login dialog, and its failure reaches script as a bare connection error).
|
||||
|
||||
So **HTTP basic auth is not a locked door here, it is a 401 the page can
|
||||
neither answer nor explain** — the same shape as the self-signed certificate
|
||||
that rules out native `wss://` below. Anything challenge-shaped fails for that
|
||||
reason; anything ambient may work. Client-certificate mTLS sits awkwardly
|
||||
between the two — the certificate is offered at the TLS layer rather than by
|
||||
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**.
|
||||
|
||||
So the honest controls are the ones that need **no credential from the page**:
|
||||
|
||||
@@ -262,15 +273,18 @@ So the honest controls are the ones that need **no credential from the page**:
|
||||
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
|
||||
- **An identity-aware tunnel** (`cloudflared` with Cloudflare Access) — the
|
||||
*ambient* case above, and the reason that distinction is drawn at all. It
|
||||
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
|
||||
own hostname, which the handshake then carries by itself; 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
|
||||
What would make this genuinely easy is a **token in the URL**, because the URL
|
||||
is the one place the page can put a credential *deliberately* — everything else
|
||||
it sends is ambient. 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**.
|
||||
|
||||
@@ -284,8 +298,8 @@ renews the certificate itself. Point a DNS name at the host, then:
|
||||
# Caddyfile
|
||||
improvise.example.com {
|
||||
# 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.
|
||||
# An allow-list rather than a password, because the page cannot answer a
|
||||
# challenge on a socket handshake: this is checked against the address.
|
||||
@denied not remote_ip 203.0.113.4 198.51.100.0/24
|
||||
abort @denied
|
||||
|
||||
|
||||
Reference in New Issue
Block a user