MiniSearch / server /utils /publicUrl.ts
system's picture
system HF Staff
Sync from felladrin/MiniSearch@6c9fb20 (part 2)
af13576 verified
Raw History Blame Contribute Delete
6.72 kB
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
/**
* Address blocks a server-side fetch of a client-supplied URL must never
* reach: loopback, link-local (which includes the cloud metadata endpoint at
* 169.254.169.254), private and carrier-grade-NAT ranges, multicast and
* reserved space. IPv4 blocks are matched in their IPv4-mapped IPv6 form, so
* one table covers both families.
*/
const BLOCKED_CIDRS = [
// Covers the unspecified address, ::1, and IPv4-compatible addresses such as
// ::127.0.0.1, which are another spelling of an IPv4 destination.
"::/96",
// NAT64, 6to4 and Teredo embed an IPv4 address that this guard cannot vet,
// so the whole prefix is refused rather than trusted.
"64:ff9b::/96",
"2002::/16",
"2001::/32",
"fc00::/7",
"fec0::/10",
"fe80::/10",
"ff00::/8",
"0.0.0.0/8",
"10.0.0.0/8",
"100.64.0.0/10",
"127.0.0.0/8",
"169.254.0.0/16",
"172.16.0.0/12",
"192.0.0.0/24",
"192.168.0.0/16",
"198.18.0.0/15",
"224.0.0.0/4",
"240.0.0.0/4",
];
const IPV4_MAPPED_PREFIX = 0xffffn << 32n;
const ADDRESS_BITS = 128n;
function parseIpv4(address: string): bigint | null {
const parts = address.split(".");
if (parts.length !== 4) return null;
let value = 0n;
for (const part of parts) {
if (!/^\d{1,3}$/.test(part)) return null;
const octet = Number(part);
if (octet > 255) return null;
value = (value << 8n) | BigInt(octet);
}
return value;
}
function parseIpv6(address: string): bigint | null {
const sides = address.split("::");
if (sides.length > 2) return null;
const toGroups = (side: string): string[] | null => {
if (side.length === 0) return [];
const groups: string[] = [];
for (const piece of side.split(":")) {
// A trailing dotted-quad ("::ffff:127.0.0.1") stands for two groups.
if (piece.includes(".")) {
const embedded = parseIpv4(piece);
if (embedded === null) return null;
groups.push(
((embedded >> 16n) & 0xffffn).toString(16),
(embedded & 0xffffn).toString(16),
);
continue;
}
if (!/^[0-9a-f]{1,4}$/i.test(piece)) return null;
groups.push(piece);
}
return groups;
};
const head = toGroups(sides[0]);
const tail = sides.length === 2 ? toGroups(sides[1]) : [];
if (head === null || tail === null) return null;
const missing = 8 - head.length - tail.length;
if (sides.length === 1 ? missing !== 0 : missing < 0) return null;
let value = 0n;
for (const group of [...head, ...Array(missing).fill("0"), ...tail]) {
value = (value << 16n) | BigInt(Number.parseInt(group, 16));
}
return value;
}
function parseAddress(address: string): bigint | null {
switch (isIP(address)) {
case 4: {
const value = parseIpv4(address);
return value === null ? null : IPV4_MAPPED_PREFIX | value;
}
case 6:
return parseIpv6(address);
default:
return null;
}
}
const BLOCKED_RANGES = BLOCKED_CIDRS.map((cidr) => {
const [address, prefix] = cidr.split("/");
const base = parseAddress(address);
if (base === null) throw new Error(`Malformed blocked CIDR: ${cidr}`);
const bits = BigInt(Number(prefix)) + (isIP(address) === 4 ? 96n : 0n);
const mask = ((1n << bits) - 1n) << (ADDRESS_BITS - bits);
return { base: base & mask, mask };
});
/**
* Whether an IP literal falls inside a range MiniSearch refuses to fetch from.
* Addresses that fail to parse are treated as blocked, since an address the
* guard cannot reason about is not one it can clear.
*/
export function isBlockedAddress(address: string): boolean {
const value = parseAddress(address);
if (value === null) return true;
return BLOCKED_RANGES.some(({ base, mask }) => (value & mask) === base);
}
/**
* Validates a URL the server is about to fetch on a client's behalf and
* returns the address the fetch must connect to.
*
* Returning the vetted address is what makes pinning possible. A caller
* that validates here and then dials the hostname again (a plain `fetch`)
* re-resolves at connect time, and that second answer can differ from the
* one that was checked β€” the DNS-rebinding window. Handing the checked
* answer to `requestPinnedToVettedAddress` in `pinnedFetch.ts` makes the
* checked address and the connected address one and the same.
*
* @returns The parsed URL and the vetted IP literal to pin the connection to
* @throws When the scheme is unsupported or the host resolves into a blocked range
*/
export async function resolvePublicUrlAndAddress(
rawUrl: string,
): Promise<{ url: URL; address: string }> {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
throw new Error(`Malformed URL: ${rawUrl}`);
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error(`Unsupported URL scheme: ${url.protocol}`);
}
// URL keeps IPv6 literals bracketed; isIP does not accept the brackets.
const hostname = url.hostname.replace(/^\[|\]$/g, "");
if (isIP(hostname)) {
if (isBlockedAddress(hostname)) {
throw new Error(`Refusing to fetch a non-public address: ${hostname}`);
}
return { url, address: hostname };
}
const addresses = await lookup(hostname, { all: true });
if (addresses.length === 0) {
throw new Error(`Could not resolve host: ${hostname}`);
}
const blocked = addresses.find(({ address }) => isBlockedAddress(address));
if (blocked) {
throw new Error(
`Refusing to fetch ${hostname}: it resolves to the non-public address ${blocked.address}`,
);
}
return { url, address: addresses[0].address };
}
/**
* Validates a URL that the server is about to fetch on a client's behalf,
* rejecting non-HTTP schemes and hosts that resolve into a blocked range.
*
* This check alone does not close the DNS-rebinding window: a caller that
* fetches by hostname after validating here re-resolves at connect time,
* so a name that answers publicly now and privately a moment later still
* gets through, and the response carries the result back. Callers that
* want the window closed must pin the connection with
* `requestPinnedToVettedAddress` using the address from
* `resolvePublicUrlAndAddress`. `/thumbnail` does; `/page-content` does
* not yet, and carries the residual: up to one GET per redirect hop from
* inside the instance's network, and the readable text of whatever those
* GETs return.
*
* @returns The parsed URL when it is safe to fetch
* @throws When the scheme is unsupported or the host resolves into a blocked range
*/
export async function resolvePublicUrl(rawUrl: string): Promise<URL> {
return (await resolvePublicUrlAndAddress(rawUrl)).url;
}