Skip to content

feat(web): expect a tunnel's own hostname, one name at a time (#648, partial) - #678

Merged
eaitbrahim merged 1 commit into
mainfrom
feat-648-external-hosts
Sep 1, 2026
Merged

feat(web): expect a tunnel's own hostname, one name at a time (#648, partial)#678
eaitbrahim merged 1 commit into
mainfrom
feat-648-external-hosts

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

⚠️ This does not complete #648, and nothing remote should ship on it. Two of the five requirements are met; three are not. The issue stays open, and docs/remote-access.md says so at the top rather than leaving it to be discovered by someone who read a changelog line and exposed a console.

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 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; 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

Worth keeping, because it's 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, 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 into click.BadParameter.

The threat model, written down

docs/remote-access.md states what each transport actually guarantees rather than "it's encrypted":

who can read it what the operator is trusting
Loopback only anything running as the operator the machine
WireGuard mesh only the operator's enrolled devices the coordination server for keys, not content
Cloudflare Tunnel Cloudflare, in the clear at its edge Cloudflare with the plaintext of every page and session token

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)

  • Session tokens over a remote origin — entropy stated against an internet-reachable attacker, issuance rate-limiting, brute-force posture. On loopback there was no attacker to rate-limit.
  • Secure-context re-verification — the service worker and manifest work because http://127.0.0.1 is 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, because HostPolicy then expects Host: 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.

…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
@eaitbrahim eaitbrahim self-assigned this Sep 1, 2026
@eaitbrahim
eaitbrahim merged commit 4c2755e into main Sep 1, 2026
4 checks passed
@eaitbrahim
eaitbrahim deleted the feat-648-external-hosts branch September 1, 2026 14:14
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