Skip to content

Refuse the private addresses written the other way - #27

Merged
davidmckayv merged 4 commits into
CopilotKit:mainfrom
beardthelion:fix/navigation-floor-ipv6
Aug 21, 2026
Merged

Refuse the private addresses written the other way#27
davidmckayv merged 4 commits into
CopilotKit:mainfrom
beardthelion:fix/navigation-floor-ipv6

Conversation

@beardthelion

@beardthelion beardthelion commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

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:160 says metadata is refused under every configuration; before this it was reachable as http://[::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:

  • Drop the root dot, so metadata.google.internal. is the name it resolves to.
  • Unwrap the IPv4 an IPv6 address carries in its low 32 bits, under each of the three prefixes that actually reach it: ::ffff:0:0/96 for a dual-stack socket, 64:ff9b::/96 for NAT64 (translated by the gateway on an IPv6-only network), and the deprecated ::/96.
  • Classify IPv6 loopback, fe80::/10 and fc00::/7 the way RFC1918 is already classified, so they sit behind the same private-host opt-in as their IPv4 equivalents.
  • Add AWS's fd00:ec2::254 to the never-allowed set, alongside the quad-form metadata address.

:: and ::1 keep their existing handling rather than being read as embedded IPv4, since their low bits are 0.0.0.0 and 0.0.0.1, which are not addresses anybody routes to. Anything in 0.0.0.0/8 is left alone for the same reason.

The bracketed "[::1]" entry comes out of INTERNAL_HOSTNAMES: canonicalization strips the brackets, so it can no longer be reached.

The second gate, found on rebasing

checkComputerAddress arrived in #51 after this branch was cut, in the same file, and compares url.hostname against NEVER_ALLOWED_HOSTNAMES directly. 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, because url.hostname keeps 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_TOKEN goes: index.ts:445 builds 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 fetch as 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, checkComputerAddress refused two.

The other direction is covered too, because a floor that refuses too much is its own outage. Public IPv6 ([2606:4700::1111]) and example.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 to 8.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 main at 2b2bc39. The server suite failure set is identical to main, 109, all integration tests wanting a Postgres this machine does not have. bun run --filter server typecheck and bunx biome check are clean on the changed files.

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
beardthelion force-pushed the fix/navigation-floor-ipv6 branch from 72c98a3 to d8f6363 Compare August 21, 2026 20:52
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.
@davidmckayv
davidmckayv merged commit 0a55adc into CopilotKit:main Aug 21, 2026
6 checks passed
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.

Navigation floor misses every IPv6 spelling of a private address, including cloud metadata

2 participants