feat(web): a remote session expires, and a wildcard bind must say what it expects (#648) - #680
Merged
Merged
Conversation
…t it expects (#648) Three of #648's five requirements, and the first finding is that one of them should NOT be built. NO RATE LIMITER, AND THE ARITHMETIC IS WHY. The issue asks for "issuance rate-limiting and brute-force posture". The token carries 256 bits (`secrets.token_urlsafe(32)`) -- a space of ~1.2e77, which at a billion guesses a second no `http.server` on a laptop will serve takes on the order of 1e60 years to dent. Guessing is not a threat this server has, and a limiter installed to stop it would be theatre: state, a failure mode, and a false sense that something was closed. `TOKEN_ENTROPY_BITS` records the number so it travels with the claim, and the comment says that if a limiter is ever added it must be justified by bounding log volume or making probing visible -- never by brute force. WHAT A REMOTE ORIGIN ACTUALLY CHANGES is who can use a token that leaked. On loopback that population is software already running as this operator, against which no session lifetime helps at all. Through a tunnel it becomes anyone who can reach the origin -- and the token has been in a URL, in terminal scrollback, and in whatever got pasted while asking for help. The 30-day cookie `Max-Age` is a BROWSER hint such an attacker ignores entirely, so the bound has to be enforced on this side of the wire or it is not a bound. A server configured with `--external-host` therefore enforces a 12-hour session lifetime, checked BEFORE the token so an expired session cannot be told apart from a wrong one by which refusal comes back. A loopback-only server has none, and that asymmetry is the argument rather than an exemption -- `SESSION_COOKIE_MAX_AGE_SECONDS`'s reasoning for the 30-day cookie is sound and is left completely intact. A WILDCARD BIND NOW REFUSES TO START WITHOUT A NAME. `--host 0.0.0.0` produced a server that refused EVERY request: `HostPolicy` then expects `Host: 0.0.0.0`, which no browser sends. It failed closed -- right direction, wrong explanation, and the operator's conclusion was "keel is broken" rather than "keel does not know which name to expect". A wildcard is precisely the bind where the name cannot be derived, because every interface has a different one, so it is the one bind that requires stating it. Not a blocklist: permitted the moment `--external-host` says what to expect. STILL OPEN, and #648 stays open for it: secure-context re-verification. The service worker and manifest work today because `http://127.0.0.1` is a secure context BY SPECIFICATION; over an external origin that property comes from HTTPS instead and has to be re-verified there. That needs a real deployed origin and cannot be closed from a checkout -- so an installed console reached through a tunnel is untested, not merely unsupported, and the doc says exactly that. 6 mutants, 6 killed. One is worth keeping: shrinking the lifetime to ONE SECOND passed everything, because the tests only pinned that it was shorter than the cookie. A lifetime that expires mid-use is not a session bound, it is the "refuses every request" outage the wildcard refusal exists to prevent -- so it is now pinned at both ends. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzuKAe2RVrPt9acVAWjRyL
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three of #648's five requirements. The first finding is that one of them should not be built.
No rate limiter, and the arithmetic is why
The issue asks for "issuance rate-limiting and brute-force posture". The token carries 256 bits (
secrets.token_urlsafe(32)) — a space of ~1.2 × 10⁷⁷. At a billion guesses per second, which nohttp.serveron a laptop will serve, covering a meaningful fraction takes on the order of 10⁶⁰ years.Guessing is not a threat this server has. A limiter installed to stop it would be theatre: state, a failure mode, and a false sense that something was closed.
TOKEN_ENTROPY_BITSrecords the number so it travels with the claim, and the comment says that if a limiter is ever added it must be justified by bounding log volume or making probing visible — never by brute force.What a remote origin actually changes
Not the strength of the token — who can use one that leaked.
On loopback that population is software already running as this operator, against which no session lifetime helps at all. Through a tunnel it becomes anyone who can reach the origin, and the token has been in a URL, in terminal scrollback, and in whatever got pasted while asking for help. The 30-day cookie
Max-Ageis a browser hint such an attacker ignores entirely.So a server configured with
--external-hostenforces a 12-hour session lifetime on its own side of the wire, checked before the token so an expired session cannot be told apart from a wrong one by which refusal comes back. A loopback-only server has none.That asymmetry is the argument, not an exemption:
SESSION_COOKIE_MAX_AGE_SECONDS's reasoning for the 30-day cookie is sound and is left completely intact. Restartingkeel serveremains the instant revocation gesture in both postures.A wildcard bind must name what it expects
--host 0.0.0.0produced a server that refused every request —HostPolicywould then expectHost: 0.0.0.0, which no browser sends. It failed closed: right direction, wrong explanation, and the operator's conclusion was "keel is broken" rather than "keel does not know which name to expect".A wildcard is precisely the bind where the name cannot be derived, because every interface has a different one. So it is the one bind that requires stating it. Not a blocklist — permitted the moment
--external-hostsays what to expect, pinned by a test, and a specific--host 10.0.0.5is untouched.Still open — #648 stays open
Secure-context re-verification. The service worker and manifest work today because
http://127.0.0.1is a secure context by specification; over an external origin that property comes from HTTPS instead and has to be re-verified there. That needs a real deployed origin and cannot be closed from a checkout — so an installed console reached through a tunnel is untested, not merely unsupported, anddocs/remote-access.mdsays exactly that.Verification
6 mutants, 6 killed. One is worth keeping: shrinking the lifetime to one second passed everything, because the tests only pinned that it was shorter than the cookie. A lifetime that expires mid-use is not a session bound — it is the "refuses every request" outage the wildcard refusal exists to prevent. It is now pinned at both ends.
Full suite 5166 passed / 3 skipped; ruff and mypy clean.