Files
gerbeur/api/remote/public-address.ts
khannurien c3594e4c27 v4: what the server and the client both state is declared once — several rules were written out in two or more files, some already drifting apart. A link's site name (its host without www.) was computed in five places, now shared/url.ts's siteHost; dumpFileVersion, which the frontend and the server had to keep equal by hand, is one function in shared/dump.ts; the Markdown flattening of the chat's reply previews and the digest's quotes is shared/markdown.ts's markdownToPlainText, so the digest also keeps an image's alt text; an attachment's URL and how to find one in a text are shared/attachment.ts, which the editor, Markdown and attachment-service read, its id pattern the new UUID_RE that the file and thumbnail routes check ids with. On the server, the Bandcamp, YouTube playlist and link preview caches use one TtlCache (api/lib/ttl-cache.ts) instead of three copies of the same expiry and eviction; the link previews' theme-color and the emails' theme tokens read colours with one parser (api/lib/css-color.ts), and a transparent theme-color now gives no tint; the WebSocket route sends through ws-service's send, the welcome included, so it is type-checked; fetch.ts strips an IPv6 literal's brackets with public-address's bareHostname; and a category's slug is checked by the same validateSlug as a dump's, with its own length. The migrations keep their frozen copies, as they document. 272 backend tests and 677 frontend tests pass, and deno check, tsc, eslint and doc:lint are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:16:52 +00:00

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];
}