Skip to content

feat(web): a remote session expires, and a wildcard bind must say what it expects (#648) - #680

Merged
eaitbrahim merged 1 commit into
mainfrom
feat-648-token-posture
Sep 1, 2026
Merged

feat(web): a remote session expires, and a wildcard bind must say what it expects (#648)#680
eaitbrahim merged 1 commit into
mainfrom
feat-648-token-posture

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

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 no http.server on 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_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

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-Age is a browser hint such an attacker ignores entirely.

So a server configured with --external-host enforces 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. Restarting keel serve remains the instant revocation gesture in both postures.

A wildcard bind must name what it expects

--host 0.0.0.0 produced a server that refused every request — HostPolicy would then expect 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, pinned by a test, and a specific --host 10.0.0.5 is untouched.

Still open — #648 stays open

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 docs/remote-access.md says 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.

…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
@eaitbrahim eaitbrahim self-assigned this Sep 1, 2026
@eaitbrahim
eaitbrahim merged commit 0c0a814 into main Sep 1, 2026
4 checks passed
@eaitbrahim
eaitbrahim deleted the feat-648-token-posture branch September 1, 2026 22:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant