From e416069c4c4617f551910845f71a6cb20679bd3e 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 1/4] 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 937879e3..a65eaf34 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; @@ -124,7 +218,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)) { @@ -141,7 +235,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 60bbd112..95d0f7fb 100644 --- a/server/tests/computer-target.test.ts +++ b/server/tests/computer-target.test.ts @@ -45,6 +45,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"); From d8f6363d339d2512d5da0afb5f9d6f904520df6e Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:08:13 -0500 Subject: [PATCH 2/4] Apply the same canonicalisation to a computer's 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. --- server/src/computer/target.ts | 6 +++++- server/tests/computer-target.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/server/src/computer/target.ts b/server/src/computer/target.ts index a65eaf34..fc77396f 100644 --- a/server/src/computer/target.ts +++ b/server/src/computer/target.ts @@ -183,7 +183,11 @@ export function checkComputerAddress(raw: string): TargetVerdict { }; } - if (NEVER_ALLOWED_HOSTNAMES.has(url.hostname.toLowerCase())) { + // Canonicalised for the same reason navigation is: the address reaches a fetch either way, so the + // spellings that gate has to see through are the spellings this one has to see through. + if ( + NEVER_ALLOWED_HOSTNAMES.has(canonicalHostname(url.hostname.toLowerCase())) + ) { return { allowed: false, reason: diff --git a/server/tests/computer-target.test.ts b/server/tests/computer-target.test.ts index 95d0f7fb..081ed0e8 100644 --- a/server/tests/computer-target.test.ts +++ b/server/tests/computer-target.test.ts @@ -175,6 +175,28 @@ describe("checkComputerAddress", () => { }, ); + /* + * The same address written the other ways, on this side too. + * + * A computer's address arrives from a provider rather than from a person, but the provider is a + * plug and the address goes straight into a fetch carrying this deployment's computer token. The + * spellings a browser resolves are the spellings a fetch resolves, so the two gates have to agree + * on what a hostname is. `[fd00:ec2::254]` is the one that shows why: it is in the refused set + * already and was still allowed, because a URL keeps the brackets and the set does not have them. + */ + test.each([ + ["http://[::ffff:169.254.169.254]/", "IPv4-mapped"], + ["http://[::ffff:a9fe:a9fe]/", "IPv4-mapped in hex"], + ["http://[64:ff9b::169.254.169.254]/", "NAT64"], + ["http://[fd00:ec2::254]/", "the IPv6 metadata endpoint"], + ])("refuses %s (%s) as a computer address", (raw) => { + const verdict = checkComputerAddress(raw); + expect(verdict.allowed).toBe(false); + if (!verdict.allowed) { + expect(verdict.reason).toContain("cloud credentials"); + } + }); + test.each(["file:///etc/passwd", "ftp://example.com", "gopher://x"])( "refuses %s, which is not a scheme a computer speaks", (raw) => { From 8d9b652ba273b657098da85caf0e9aae7e1c4d51 Mon Sep 17 00:00:00 2001 From: David McKay Date: Fri, 21 Aug 2026 13:31:29 -0700 Subject: [PATCH 3/4] Unwrap the mapped form of 0.0.0.0, and refuse the task-role endpoint 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. --- server/src/computer/target.ts | 21 ++++++++++++++++++++- server/tests/computer-target.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/server/src/computer/target.ts b/server/src/computer/target.ts index fc77396f..a0874141 100644 --- a/server/src/computer/target.ts +++ b/server/src/computer/target.ts @@ -28,6 +28,15 @@ const NEVER_ALLOWED_HOSTNAMES = new Set([ // 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", + /* + * The ECS and Fargate task-role endpoint. Not the instance metadata address above, and on Fargate + * it is the only one there is: a task's IAM credentials are served from it, which is exactly what + * this list exists to keep a Bot away from, and docs/deployment.md documents Fargate as a target. + */ + "169.254.170.2", + // Alibaba Cloud's equivalent, which also sits outside the usual link-local handling because + // 100.64.0.0/10 is carrier-grade NAT rather than private space. + "100.100.100.200", ]); /** Hostnames inside the deployment. Reachable only when a deployment opts in. */ @@ -87,7 +96,17 @@ function embeddedIpv4(groups: number[]): string | null { 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 + /* + * Only the compatible form keeps its 0.0.0.0/8 addresses as IPv6. `::` and `::1` live there and are + * what `isPrivateIpv6` below recognises. + * + * The mapped and NAT64 forms are 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 this 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. + */ + if (compatible && high >> 8 === 0) return null; return [high >> 8, high & 255, low >> 8, low & 255].join("."); } diff --git a/server/tests/computer-target.test.ts b/server/tests/computer-target.test.ts index 081ed0e8..68b5b749 100644 --- a/server/tests/computer-target.test.ts +++ b/server/tests/computer-target.test.ts @@ -211,4 +211,29 @@ describe("checkComputerAddress", () => { expect(verdict.reason).toContain("not a URL"); } }); + test("refuses 0.0.0.0 written as a mapped IPv6 address", () => { + // Only the compatible form keeps 0.0.0.0/8 as IPv6, because :: and ::1 live there. + expect(checkNavigationTarget("http://[::ffff:0.0.0.0]:5432/").allowed).toBe( + false, + ); + expect(checkNavigationTarget("http://[::ffff:0:0]:5432/").allowed).toBe( + false, + ); + expect(checkNavigationTarget("http://[::]/").allowed).toBe(false); + expect(checkNavigationTarget("http://[::1]/").allowed).toBe(false); + }); + + test("refuses the container credential endpoints even with private hosts allowed", () => { + // Never allowed means never: the opt-in is the weakest configuration and is when this holds. + const allowed = { allowPrivateHosts: true }; + expect(checkNavigationTarget("http://169.254.170.2/", allowed).allowed).toBe( + false, + ); + expect( + checkNavigationTarget("http://100.100.100.200/", allowed).allowed, + ).toBe(false); + expect(checkNavigationTarget("http://10.0.0.5/", allowed).allowed).toBe( + true, + ); + }); }); From 7f99a3905a975d98a01ce67620545402f5a11af1 Mon Sep 17 00:00:00 2001 From: David McKay Date: Fri, 21 Aug 2026 14:05:16 -0700 Subject: [PATCH 4/4] Format the new address tests the way the formatter wants them --- server/tests/computer-target.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/tests/computer-target.test.ts b/server/tests/computer-target.test.ts index 68b5b749..5509a629 100644 --- a/server/tests/computer-target.test.ts +++ b/server/tests/computer-target.test.ts @@ -226,9 +226,9 @@ describe("checkComputerAddress", () => { test("refuses the container credential endpoints even with private hosts allowed", () => { // Never allowed means never: the opt-in is the weakest configuration and is when this holds. const allowed = { allowPrivateHosts: true }; - expect(checkNavigationTarget("http://169.254.170.2/", allowed).allowed).toBe( - false, - ); + expect( + checkNavigationTarget("http://169.254.170.2/", allowed).allowed, + ).toBe(false); expect( checkNavigationTarget("http://100.100.100.200/", allowed).allowed, ).toBe(false);