/** * Guards for server-side fetches of user-supplied URLs (link previews, the * image proxy, Bandcamp resolution): the server must not become a way to reach * addresses only it can see — loopback, the LAN, container networks, or the * cloud metadata endpoint at 169.254.169.254. * * @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 IP literal outside the public internet. Non-IPs * are false. */ 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. */ function bareHostname(url: URL): string { return url.hostname.replace(/^\[(.*)\]$/, "$1"); } /** * Checks whether `url`'s host is private without DNS: a private IP literal, * or a name that is local by definition. Names still need resolving (see * resolvePublicAddress) — this only rejects what is obvious without DNS. */ export function isPrivateHostname(url: URL): boolean { const host = bareHostname(url).toLowerCase(); if (host === "localhost" || host.endsWith(".localhost")) return true; return isPrivateAddress(host); } /** * Resolves `url`'s host and returns one of its addresses, or throws if the * host is private or any address it resolves to is (a name with both a public * and a private record is refused outright, since the fetch may pick either). * * The fetch itself resolves again, so a DNS server answering differently the * second time (rebinding) isn't fully excluded; the curl fallback pins the * address this returns. * * @throws {Error} When the host is private, can't be resolved, or resolves to * a private address. */ export async function resolvePublicAddress(url: URL): Promise { 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]; }