diff --git a/server/src/computer/target.ts b/server/src/computer/target.ts index 937879e3..a0874141 100644 --- a/server/src/computer/target.ts +++ b/server/src/computer/target.ts @@ -25,6 +25,18 @@ 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", + /* + * 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. */ @@ -33,13 +45,114 @@ 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; + /* + * 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("."); +} + +/** + * 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; @@ -89,7 +202,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: @@ -124,7 +241,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 +258,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..5509a629 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"); @@ -113,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) => { @@ -127,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, + ); + }); });