From 013c869bc2b76e243723e1546bc3341216939490 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:32:28 -0500 Subject: [PATCH] Refuse the private addresses written the other way 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. --- server/src/computer/target.ts | 104 ++++++++++++++++++++++++++- server/tests/computer-target.test.ts | 62 ++++++++++++++++ 2 files changed, 163 insertions(+), 3 deletions(-) diff --git a/server/src/computer/target.ts b/server/src/computer/target.ts index 99d7719..6316308 100644 --- a/server/src/computer/target.ts +++ b/server/src/computer/target.ts @@ -25,6 +25,9 @@ const NEVER_ALLOWED_HOSTNAMES = new Set([ "169.254.169.254", "metadata.google.internal", "metadata.goog", + // The same endpoint over IPv6. A container with an IPv6 stack reaches its credentials here and + // nowhere in the quad-form list above says so. + "fd00:ec2::254", ]); /** Hostnames inside the deployment. Reachable only when a deployment opts in. */ @@ -33,13 +36,104 @@ const INTERNAL_HOSTNAMES = new Set([ "127.0.0.1", "0.0.0.0", "::1", - "[::1]", ]); export type TargetVerdict = | { allowed: true; url: string } | { allowed: false; reason: string }; +/** + * The hostname reduced to the one form the lists below are written in. + * + * A URL can carry the same destination several ways and the browser resolves all of them: + * `metadata.google.internal.` with the root dot, and any of the IPv6 spellings that carry an IPv4 + * address in their last 32 bits. Matching the string a caller happened to type means a deny list with + * a door in it, so every form is reduced here before anything is compared. + * + * `new URL()` has already done the parts of this that are its business: it lower-cases the host, + * compresses IPv6, and turns `127.1` into `127.0.0.1`. What it does not do is unwrap an embedded IPv4 + * or drop the root dot, because both are legal and it is not the one deciding anything. + */ +function canonicalHostname(hostname: string): string { + // The root dot. `example.com.` and `example.com` are the same name to DNS and to Chromium. + const name = hostname.replace(/\.+$/, ""); + + if (!name.startsWith("[") || !name.endsWith("]")) return name; + const inner = name.slice(1, -1); + + const groups = expandIpv6(inner); + if (!groups) return inner; + return embeddedIpv4(groups) ?? inner; +} + +/** + * The IPv4 address an IPv6 one is carrying, if it is carrying one. + * + * Three prefixes put a whole IPv4 address in the low 32 bits and all three reach it: `::ffff:0:0/96` + * is what a dual-stack socket uses, `64:ff9b::/96` is the well-known NAT64 prefix and translates on + * any IPv6-only network with a gateway, and `::/96` is the deprecated compatible form. Whichever way + * it was written, the destination is the IPv4 address, so that is what the rules should see. + * + * `::` and `::1` also have zeros in the top 96 bits and are NOT this: 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 therefore left alone + * and handled as the IPv6 address it is. + */ +function embeddedIpv4(groups: number[]): string | null { + const [a, b, c, d, e, f, high = 0, low = 0] = groups; + const zeros = a === 0 && b === 0 && c === 0 && d === 0 && e === 0; + const mapped = zeros && f === 0xffff; + const compatible = zeros && f === 0; + const nat64 = + a === 0x64 && b === 0xff9b && c === 0 && d === 0 && e === 0 && f === 0; + + if (!mapped && !compatible && !nat64) return null; + if (high >> 8 === 0) return null; // 0.0.0.0/8: not a destination, so not an embedded address + + return [high >> 8, high & 255, low >> 8, low & 255].join("."); +} + +/** + * The IPv6 ranges that are this deployment rather than the internet. + * + * Loopback, link-local (`fe80::/10`) and unique-local (`fc00::/7`, which is where cloud providers put + * internal endpoints) are the IPv6 answers to the RFC1918 list above. The unspecified address is here + * too: `[::]` reaches localhost the same way `0.0.0.0` does. + */ +function isPrivateIpv6(hostname: string): boolean { + if (!hostname.includes(":")) return false; + const groups = expandIpv6(hostname); + if (!groups) return false; + + if (groups.every((group) => group === 0)) return true; // :: + if (groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1) { + return true; // ::1 + } + + const [first] = groups as [number, ...number[]]; + if (first >= 0xfe80 && first <= 0xfebf) return true; // fe80::/10 + if (first >= 0xfc00 && first <= 0xfdff) return true; // fc00::/7 + return false; +} + +/** The eight groups of an IPv6 address, or null if it is not one. Compression is expanded. */ +function expandIpv6(hostname: string): number[] | null { + const halves = hostname.split("::"); + if (halves.length > 2) return null; + + const parse = (part: string) => + part === "" + ? [] + : part.split(":").map((group) => Number.parseInt(group, 16)); + const head = parse(halves[0] ?? ""); + const tail = halves.length === 2 ? parse(halves[1] ?? "") : []; + if ([...head, ...tail].some((group) => Number.isNaN(group))) return null; + + const missing = 8 - head.length - tail.length; + if (halves.length === 1) return head.length === 8 ? head : null; + if (missing < 0) return null; + return [...head, ...Array(missing).fill(0), ...tail]; +} + function isPrivateIpv4(hostname: string): boolean { const parts = hostname.split("."); if (parts.length !== 4) return false; @@ -80,7 +174,7 @@ export function checkNavigationTarget( }; } - const hostname = url.hostname.toLowerCase(); + const hostname = canonicalHostname(url.hostname.toLowerCase()); // Checked before the opt-in, so no configuration can reach it. if (NEVER_ALLOWED_HOSTNAMES.has(hostname)) { @@ -97,7 +191,11 @@ export function checkNavigationTarget( return { allowed: true, url: url.toString() }; } - if (INTERNAL_HOSTNAMES.has(hostname) || isPrivateIpv4(hostname)) { + if ( + INTERNAL_HOSTNAMES.has(hostname) || + isPrivateIpv4(hostname) || + isPrivateIpv6(hostname) + ) { return { allowed: false, reason: diff --git a/server/tests/computer-target.test.ts b/server/tests/computer-target.test.ts index 36456b3..5a56598 100644 --- a/server/tests/computer-target.test.ts +++ b/server/tests/computer-target.test.ts @@ -42,6 +42,68 @@ describe("navigation targets", () => { } }); + // The same destinations written the other ways a URL can carry them. Chromium resolves every one + // of these to the address the tests above refuse, so a floor that only matches dotted quads and + // exact names is a floor with a door in it. + test.each([ + [ + "http://[::ffff:169.254.169.254]/latest/meta-data/", + "IPv4-mapped metadata", + ], + ["http://[fd00:ec2::254]/latest/meta-data/", "AWS metadata over IPv6"], + ["http://metadata.google.internal./", "metadata name with a trailing dot"], + [ + "http://[64:ff9b::169.254.169.254]/latest/meta-data/", + "metadata behind the NAT64 prefix", + ], + [ + "http://[::169.254.169.254]/latest/meta-data/", + "metadata as an IPv4-compatible address", + ], + ])("refuses %s (%s) even with private hosts allowed", (url) => { + for (const allowPrivateHosts of [false, true]) { + const verdict = checkNavigationTarget(url, { allowPrivateHosts }); + + expect(verdict.allowed).toBe(false); + expect(verdict.allowed === false && verdict.reason).toContain( + "cloud credentials", + ); + } + }); + + test.each([ + ["http://[::ffff:127.0.0.1]/", "IPv4-mapped loopback"], + ["http://[::ffff:10.0.0.5]/", "IPv4-mapped RFC1918"], + ["http://[fe80::1]/", "link-local IPv6"], + ["http://[fc00::1]/", "unique local IPv6"], + ["http://[0:0:0:0:0:0:0:1]/", "IPv6 loopback written out in full"], + ])("refuses %s (%s)", (url) => { + const verdict = checkNavigationTarget(url); + + expect(verdict.allowed).toBe(false); + expect(verdict.allowed === false && verdict.reason).toContain( + "inside this deployment's own network", + ); + }); + + // The opt-in still means what it says for these forms: a laptop deployment browsing its own + // services over IPv6 is the case it exists for. + test("allows IPv6 private addresses when the deployment opts in", () => { + expect( + checkNavigationTarget("http://[::ffff:127.0.0.1]:3000/", { + allowPrivateHosts: true, + }).allowed, + ).toBe(true); + }); + + // Public IPv6 is most of the internet. Refusing it to be safe would be its own outage. + test("allows ordinary public IPv6", () => { + expect(checkNavigationTarget("http://[2606:4700::1111]/").allowed).toBe( + true, + ); + expect(checkNavigationTarget("https://example.com./").allowed).toBe(true); + }); + test("refuses a non-web scheme, naming it", () => { const verdict = checkNavigationTarget("file:///etc/passwd");