Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 101 additions & 3 deletions server/src/computer/target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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;
Expand Down Expand Up @@ -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)) {
Expand All @@ -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:
Expand Down
62 changes: 62 additions & 0 deletions server/tests/computer-target.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down