Refuse the private addresses written the other way - #27
Merged
davidmckayv merged 4 commits intoAug 21, 2026
Conversation
beardthelion
requested review from
MikeRyanDev,
davidmckayv,
guidovizoso and
tylerslaton
as code owners
August 20, 2026 02:57
beardthelion
force-pushed
the
fix/navigation-floor-ipv6
branch
from
August 21, 2026 20:02
013c869 to
4d002e4
Compare
The navigation floor matched exact hostnames and dotted-quad IPv4, so every IPv6 spelling of the same destination went straight past it. A Bot talked into opening http://[::ffff:169.254.169.254]/ reached the cloud metadata endpoint and screenshotted the deployment's credentials back into the transcript, which is the one thing the architecture doc promises cannot happen under any configuration. The same hole covered mapped loopback and RFC1918, AWS's IPv6 metadata address, link-local and unique-local IPv6, and a trailing root dot on any of the refused names. Reduce the hostname to one form before comparing anything: drop the root dot, and unwrap the IPv4 an IPv6 address carries in its low 32 bits under any of the three prefixes that reach it, the dual-stack ::ffff:0:0/96, the NAT64 well-known 64:ff9b::/96, and the deprecated compatible ::/96. Then classify IPv6 the way RFC1918 is already classified, so loopback, link-local and unique-local sit behind the same opt-in as their IPv4 equivalents while public IPv6 stays reachable. :: and ::1 keep their own handling: their low bits are 0.0.0.0 and 0.0.0.1, which are not addresses anybody routes to, so 0.0.0.0/8 is left alone rather than read as an embedded address.
`checkComputerAddress` arrived after this branch was cut and compares the hostname a URL happens to carry against the never-allowed set directly, so every spelling the navigation gate now sees through was still allowed there. The address goes straight into a fetch carrying this deployment's computer token, so a provider answering with `[::ffff:169.254.169.254]` reads the deployment's own cloud credentials. `[fd00:ec2::254]` is the case that shows the shape of it. That address is already in the refused set and was still allowed, because `url.hostname` keeps the brackets for an IPv6 literal and the set does not have them, so the comparison could never match. The two gates decide different questions and should: a computer on loopback or a private address is the ordinary case and navigation refuses both. What they cannot disagree about is what a hostname is, since the same spellings resolve for a fetch as for a browser.
beardthelion
force-pushed
the
fix/navigation-floor-ipv6
branch
from
August 21, 2026 20:52
72c98a3 to
d8f6363
Compare
The 0.0.0.0/8 carve-out applied to all three embedded forms. It is only needed for the compatible one, because :: and ::1 live in that range and are the IPv6 addresses isPrivateIpv6 recognises. The mapped form is different: [::ffff:0.0.0.0] is what a dual-stack socket calls 0.0.0.0, and 0.0.0.0 reaches every port bound on the host. Left wrapped it matched neither the internal hostname list, which holds the bare quad form, nor isPrivateIpv6, which sees ffff in the sixth group and moves on. Verified before the change: [::ffff:0.0.0.0]:5432 was allowed with the private-host opt-in off. 169.254.170.2 was also missing. That is the ECS and Fargate task-role endpoint, not the instance metadata address, and on Fargate it is the only one there is: a task's IAM credentials are served from it, and docs/deployment.md documents Fargate as a target. Alibaba's 100.100.100.200 goes with it, which sits outside the usual link-local handling because 100.64.0.0/10 is carrier-grade NAT. Both are in the never-allowed set, so they hold with the private-host opt-in on, which is the configuration that needs them most.
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.
Closes #25.
The navigation floor compared exact hostnames and dotted-quad IPv4, so every IPv6 spelling of the same destination went past it, cloud metadata included.
docs/architecture.md:160says metadata is refused under every configuration; before this it was reachable ashttp://[::ffff:169.254.169.254]/whatever the configuration said.The same function backs
checkAgentEndpoint, where a registered agent is a URL this server POSTs to on every run, so the hole was reachable from agent registration as well as from the browser.What it does
Reduce the hostname to one form before comparing anything:
metadata.google.internal.is the name it resolves to.::ffff:0:0/96for a dual-stack socket,64:ff9b::/96for NAT64 (translated by the gateway on an IPv6-only network), and the deprecated::/96.fe80::/10andfc00::/7the way RFC1918 is already classified, so they sit behind the same private-host opt-in as their IPv4 equivalents.fd00:ec2::254to the never-allowed set, alongside the quad-form metadata address.::and::1keep their existing handling rather than being read as embedded IPv4, since their low bits are0.0.0.0and0.0.0.1, which are not addresses anybody routes to. Anything in0.0.0.0/8is left alone for the same reason.The bracketed
"[::1]"entry comes out ofINTERNAL_HOSTNAMES: canonicalization strips the brackets, so it can no longer be reached.The second gate, found on rebasing
checkComputerAddressarrived in #51 after this branch was cut, in the same file, and comparesurl.hostnameagainstNEVER_ALLOWED_HOSTNAMESdirectly. So every spelling the navigation gate now sees through was still allowed there, and the second commit applies the same canonicalisation to it.[fd00:ec2::254]is the case that shows the shape of it. That address is already in the refused set and was still allowed, becauseurl.hostnamekeeps the brackets for an IPv6 literal and the set entry has none, so the comparison could never match however it was written.This is worth more than it looks. The address that function guards is where
COMPUTER_TOKENgoes:index.ts:445builds the live-screen websocket URL from it with the token in the query string, and the comment there says the check exists for exactly the case of a provider answering with a foreign host.The two gates keep deciding different questions, which is right. A computer on loopback or a private address is the ordinary case and navigation refuses both. What they cannot disagree about is what a hostname is, since the same spellings resolve for a
fetchas for a browser.Verification
16 test cases added to
server/tests/computer-target.test.ts, all failing before the change and passing after.Twelve on navigation: mapped metadata, AWS IPv6 metadata, the trailing root dot, NAT64, the IPv4-compatible form, mapped loopback and RFC1918, link-local and unique-local.
Four on the computer address, the four spellings that gate let through, checked before the fix by running the same matrix against both functions: navigation refused all seven forms,
checkComputerAddressrefused two.The other direction is covered too, because a floor that refuses too much is its own outage. Public IPv6 (
[2606:4700::1111]) andexample.com.stay allowed, mapped loopback is allowed when the deployment opts in, and metadata stays refused under both settings. For the computer address I ran the must-allow set separately: loopback v4 and v6, a container name, a private address, a ULA, and a public NAT64 address unwrapping to8.8.8.8, all still allowed. That gate has to keep permitting private addresses, so a fix that simply reused the navigation rule there would have refused every real deployment.Rebased onto
mainat 2b2bc39. Theserversuite failure set is identical tomain, 109, all integration tests wanting a Postgres this machine does not have.bun run --filter server typecheckandbunx biome checkare clean on the changed files.