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");