156 lines
5.5 KiB
TypeScript
156 lines
5.5 KiB
TypeScript
/**
|
|
* Keeps a user-supplied URL from making the server fetch what only it can
|
|
* reach: loopback, the LAN, container networks or the cloud metadata endpoint
|
|
* at 169.254.169.254. It tells private addresses from public ones, and
|
|
* `fetch.ts` checks every hop with it.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
function parseIPv4(s: string): number[] | null {
|
|
const parts = s.split(".");
|
|
if (parts.length !== 4) return null;
|
|
const bytes = parts.map((p) => /^\d{1,3}$/.test(p) ? Number(p) : NaN);
|
|
return bytes.every((b) => b >= 0 && b <= 255) ? bytes : null;
|
|
}
|
|
|
|
function isPrivateIPv4([a, b]: number[]): boolean {
|
|
return a === 0 || // "this" network
|
|
a === 10 ||
|
|
a === 127 ||
|
|
(a === 100 && b >= 64 && b <= 127) || // carrier-grade NAT
|
|
(a === 169 && b === 254) || // link-local, cloud metadata
|
|
(a === 172 && b >= 16 && b <= 31) ||
|
|
(a === 192 && b === 0) || // IETF protocol assignments (192.0.0.0/24 …)
|
|
(a === 192 && b === 168) ||
|
|
(a === 198 && (b === 18 || b === 19)) || // benchmarking
|
|
a >= 224; // multicast, reserved, broadcast
|
|
}
|
|
|
|
/** Parses `s` into its 16 bytes, or null when it isn't an IPv6 address. */
|
|
function parseIPv6(s: string): number[] | null {
|
|
let addr = s.toLowerCase().replace(/%.*$/, ""); // drop a zone id
|
|
// An embedded dotted quad (::ffff:10.0.0.1) becomes the last two groups.
|
|
const lastColon = addr.lastIndexOf(":");
|
|
if (lastColon === -1) return null;
|
|
const tail = addr.slice(lastColon + 1);
|
|
if (tail.includes(".")) {
|
|
const v4 = parseIPv4(tail);
|
|
if (!v4) return null;
|
|
addr = `${addr.slice(0, lastColon + 1)}${
|
|
((v4[0] << 8) | v4[1]).toString(16)
|
|
}:${((v4[2] << 8) | v4[3]).toString(16)}`;
|
|
}
|
|
const halves = addr.split("::");
|
|
if (halves.length > 2) return null;
|
|
const head = halves[0] ? halves[0].split(":") : [];
|
|
const rest = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
|
|
const fill = 8 - head.length - rest.length;
|
|
if (halves.length === 1 ? fill !== 0 : fill < 1) return null;
|
|
const groups = [
|
|
...head,
|
|
...Array(halves.length === 2 ? fill : 0).fill("0"),
|
|
...rest,
|
|
];
|
|
if (!groups.every((g) => /^[0-9a-f]{1,4}$/.test(g))) return null;
|
|
return groups.flatMap((g) => {
|
|
const n = parseInt(g, 16);
|
|
return [n >> 8, n & 0xff];
|
|
});
|
|
}
|
|
|
|
function isPrivateIPv6(b: number[]): boolean {
|
|
const zeroPrefix = (n: number) => b.slice(0, n).every((x) => x === 0);
|
|
// IPv4 carried inside IPv6 is judged as the IPv4 address it carries.
|
|
if (zeroPrefix(10) && b[10] === 0xff && b[11] === 0xff) { // ::ffff:a.b.c.d
|
|
return isPrivateIPv4(b.slice(12));
|
|
}
|
|
if (zeroPrefix(12)) { // ::, ::1 and the deprecated ::a.b.c.d
|
|
return true;
|
|
}
|
|
if (
|
|
// 64:ff9b::/96 NAT64
|
|
b[0] === 0 && b[1] === 0x64 && b[2] === 0xff && b[3] === 0x9b &&
|
|
b.slice(4, 12).every((x) => x === 0)
|
|
) return isPrivateIPv4(b.slice(12));
|
|
if (b[0] === 0x20 && b[1] === 0x02) { // 2002::/16 6to4
|
|
return isPrivateIPv4(b.slice(2, 6));
|
|
}
|
|
return (b[0] & 0xfe) === 0xfc || // fc00::/7 unique local
|
|
(b[0] === 0xfe && (b[1] & 0xc0) === 0x80) || // fe80::/10 link-local
|
|
(b[0] === 0xfe && (b[1] & 0xc0) === 0xc0) || // fec0::/10 site-local
|
|
b[0] === 0xff; // multicast
|
|
}
|
|
|
|
/**
|
|
* Checks whether `ip` is an IPv4 or IPv6 address outside the public internet.
|
|
* An IPv4 address carried in IPv6 is judged as IPv4. Anything that isn't an IP
|
|
* address is false, so a hostname must be resolved first.
|
|
*/
|
|
export function isPrivateAddress(ip: string): boolean {
|
|
const v4 = parseIPv4(ip);
|
|
if (v4) return isPrivateIPv4(v4);
|
|
const v6 = parseIPv6(ip);
|
|
return v6 ? isPrivateIPv6(v6) : false;
|
|
}
|
|
|
|
function isIpLiteral(host: string): boolean {
|
|
return parseIPv4(host) !== null || parseIPv6(host) !== null;
|
|
}
|
|
|
|
/**
|
|
* Returns `url`'s hostname without the brackets `URL.hostname` keeps around
|
|
* an IPv6 literal: the address checks here read it, and `fetch.ts` hands it
|
|
* to curl's `--resolve`.
|
|
*/
|
|
export function bareHostname(url: URL): string {
|
|
return url.hostname.replace(/^\[(.*)\]$/, "$1");
|
|
}
|
|
|
|
/**
|
|
* Checks whether the host of `url` is private without a DNS lookup: a private
|
|
* IP address, or `localhost` and its subdomains. `isValidHttpUrl` uses it to
|
|
* reject a link early; only {@link resolvePublicAddress} is a real guard.
|
|
*/
|
|
export function isPrivateHostname(url: URL): boolean {
|
|
const host = bareHostname(url).toLowerCase();
|
|
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
|
return isPrivateAddress(host);
|
|
}
|
|
|
|
/**
|
|
* Resolves the host of `url` and returns one of its addresses, all of which
|
|
* must be public: the fetch may pick any of them. `fetchWithTimeout` calls it
|
|
* before each hop.
|
|
*
|
|
* Only the curl fallback connects to the address returned. Deno's fetch
|
|
* resolves the name again, so a DNS server that answers differently the second
|
|
* time (rebinding) can still get through.
|
|
*
|
|
* @throws {Error} When the host is private, can't be resolved, or resolves to
|
|
* a private address.
|
|
*/
|
|
export async function resolvePublicAddress(url: URL): Promise<string> {
|
|
if (isPrivateHostname(url)) {
|
|
throw new Error(`Refusing to fetch private address ${url.hostname}`);
|
|
}
|
|
const host = bareHostname(url);
|
|
if (isIpLiteral(host)) return host;
|
|
|
|
const results = await Promise.allSettled([
|
|
Deno.resolveDns(host, "A"),
|
|
Deno.resolveDns(host, "AAAA"),
|
|
]);
|
|
const addresses = results.flatMap((r) =>
|
|
r.status === "fulfilled" ? r.value : []
|
|
);
|
|
if (addresses.length === 0) {
|
|
throw new Error(`Could not resolve ${host}`);
|
|
}
|
|
const blocked = addresses.find(isPrivateAddress);
|
|
if (blocked) {
|
|
throw new Error(`Refusing to fetch ${host}: resolves to ${blocked}`);
|
|
}
|
|
return addresses[0];
|
|
}
|