feat(web): expect a tunnel's own hostname, one name at a time (#648, partial) - #678
Merged
Conversation
…partial)⚠️ THIS DOES NOT COMPLETE #648 AND NOTHING REMOTE SHOULD SHIP ON IT. Two of the five requirements are met; three are not, and they are named at the top of `docs/remote-access.md` rather than left to be discovered by someone who read a changelog line and exposed a console. WHAT IS DONE. A Cloudflare Tunnel forwards to loopback and presents the app's PUBLIC domain in `Host:`. The DNS-rebinding defence refuses it -- correctly, and for exactly the same reason it refuses `evil.example`: from inside the process the two are indistinguishable, both being names that resolve to a machine this server did not bind. Nothing in the request can tell them apart. Only the operator can, so only the operator can name one: keel serve --external-host keel.example.com EXTENDS, NEVER REPLACES. Checked before the bind-derived rules and changing neither: every loopback spelling still answers, every unlisted name is still refused, and the port check still applies to a proxied request. Matching is case-insensitive because DNS is, and an operator whose correct-looking config silently refused everything would have no way to see why. NO WILDCARDS, REFUSED AT STARTUP. `*`, `*.example.com`, `.example.com`, `0.0.0.0`, `::`, `any`, `all` and the empty string all raise. A wildcard is not a wider expectation, it is the ABSENCE of one -- `*` answers `evil.example` exactly as readily as the operator's own domain, which is the attack. A leading-dot suffix admits every subdomain an attacker can provision, and a tunnel presents one name. THE BUG ITS OWN TEST FOUND, kept because it is the whole argument for testing a control rather than reading it. `ServeConfig.host_policy` is a lazy property, so `HostPolicy.__post_init__`'s wildcard guard fired on the first REQUEST rather than at startup: `--external-host '*.example.com'` started cleanly, said nothing, and would have raised somewhere inside a handler. The command now builds a policy eagerly and turns the refusal into `click.BadParameter`. THE THREAT MODEL, WRITTEN DOWN. `docs/remote-access.md` states what each transport actually guarantees rather than "it's encrypted". A WireGuard mesh is end-to-end between the operator's own devices. A Cloudflare Tunnel TERMINATES TLS AT CLOUDFLARE -- so in that mode the operator is not trusting Cloudflare to route traffic, they are handing it the readable contents of a console showing positions, balances and attestations. That may be an acceptable trade. It is not a neutral one, and a document that let someone find out later would have failed. STILL OPEN, and each is a real gap: session-token entropy and rate-limiting against an attacker who can reach the origin at all (on loopback there was no attacker to rate-limit); secure-context re-verification of the service worker and manifest over HTTPS rather than assumed from `http://127.0.0.1`'s by-specification secure context; and a decision about `--host 0.0.0.0`, which today produces a server that refuses every request because `HostPolicy` then expects `Host: 0.0.0.0` -- fails closed, which is the safe direction, but fails confusingly. 10 mutants, 10 killed. The last was CLI-side normalisation, invisible until a test typed the name the way a shell or a config file hands it over. 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.
The problem
A Cloudflare Tunnel forwards to loopback and presents the app's public domain in
Host:. The DNS-rebinding defence refuses it — correctly, and for exactly the same reason it refusesevil.example: from inside the process the two are indistinguishable, both being names that resolve to a machine this server did not bind.Nothing in the request can tell them apart. Only the operator can, so only the operator can name one:
Extends, never replaces
Checked before the bind-derived rules and changing neither — every loopback spelling still answers, every unlisted name is still refused, and the port check still applies to a proxied request. Matching is case-insensitive because DNS is; an operator whose correct-looking config silently refused everything would have no way to see why.
No wildcards, refused at startup.
*,*.example.com,.example.com,0.0.0.0,::,any,alland the empty string all raise. A wildcard is not a wider expectation, it is the absence of one —*answersevil.exampleexactly as readily as the operator's own domain, which is the attack. A leading-dot suffix admits every subdomain an attacker can provision, and a tunnel presents one name.The bug its own test found
Worth keeping, because it's the whole argument for testing a control rather than reading it.
ServeConfig.host_policyis a lazy property, soHostPolicy.__post_init__'s wildcard guard fired on the first request, not at startup.--external-host '*.example.com'started cleanly, said nothing, and would have raised somewhere inside a handler. My docstring claimed the opposite. The command now builds a policy eagerly and converts the refusal intoclick.BadParameter.The threat model, written down
docs/remote-access.mdstates what each transport actually guarantees rather than "it's encrypted":That third row needs saying out loud. Cloudflare terminates TLS. In that mode the operator isn't trusting it to route traffic — they're handing it the readable contents of a console showing positions, balances and attestations. That may be an acceptable trade; it is not a neutral one.
Still open (#648 stays open)
http://127.0.0.1is a secure context by specification. Over an external origin that comes from HTTPS instead and must be re-verified, not assumed.--host 0.0.0.0— today it produces a server that refuses every request, becauseHostPolicythen expectsHost: 0.0.0.0, which no browser sends. Fails closed, which is the safe direction, but fails confusingly and needs its own decision.Verification
10 mutants, 10 killed. The last was CLI-side normalisation — invisible until a test typed the name the way a shell or config file actually hands it over (
" KEEL.Example.COM ").Full suite 5145 passed / 3 skipped; ruff and mypy clean.