import { isIP } from 'node:net'; import { MAX_HTTP_URL_SIZE } from './constants'; export type ArticleAddressClass = 'public' | 'private' | 'forbidden'; export interface ArticleDnsAnswer { address: string; family: 4 | 6; } export type ArticleDnsResolver = (hostname: string) => Promise; export interface ResolvedArticleDestination { logicalUrl: URL; transportUrl: URL; address: string; family: 4 | 6; hostHeader: string; tlsServerName?: string; } export class ArticleDestinationBlockedError extends Error { readonly code = 'ARTICLE_DESTINATION_BLOCKED'; constructor(message: string) { super(message); this.name = 'ArticleDestinationBlockedError'; } } const ALWAYS_BLOCKED_HOSTS = new Set(['metadata.google.internal']); const ALWAYS_BLOCKED_IPV4 = new Set([ '100.100.100.200', // Alibaba metadata '168.63.129.16', // Azure WireServer / platform virtual IP '169.254.169.254', // Common cloud instance metadata ]); function ipv4Number(address: string): number | undefined { const octets = address.split('.'); if (octets.length !== 4) return undefined; let value = 0; for (const octet of octets) { if (!/^\d{1,3}$/.test(octet)) return undefined; const parsed = Number(octet); if (parsed > 255) return undefined; value = (value * 256 + parsed) >>> 0; } return value; } function inIpv4Cidr(value: number, base: string, prefix: number): boolean { const baseValue = ipv4Number(base); if (baseValue === undefined) return false; const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0; return (value & mask) === (baseValue & mask); } function parseIpv6(address: string): number[] | undefined { let source = address.toLowerCase(); const zoneIndex = source.indexOf('%'); if (zoneIndex >= 0) return undefined; if (source.includes('.')) { const separator = source.lastIndexOf(':'); if (separator < 0) return undefined; const embedded = ipv4Number(source.slice(separator + 1)); if (embedded === undefined) return undefined; source = `${source.slice(0, separator)}:${(embedded >>> 16).toString(16)}:${(embedded & 0xffff).toString(16)}`; } const halves = source.split('::'); if (halves.length > 2) return undefined; const parseHalf = (half: string): number[] | undefined => { if (half === '') return []; const parts = half.split(':'); const values: number[] = []; for (const part of parts) { if (!/^[0-9a-f]{1,4}$/.test(part)) return undefined; values.push(Number.parseInt(part, 16)); } return values; }; const left = parseHalf(halves[0] ?? ''); const right = parseHalf(halves[1] ?? ''); if (!left || !right) return undefined; if (halves.length === 1) return left.length === 8 ? left : undefined; const zeroCount = 8 - left.length - right.length; if (zeroCount < 1) return undefined; return [...left, ...new Array(zeroCount).fill(0), ...right]; } function embeddedIpv4(high: number, low: number): string { return `${high >>> 8}.${high & 0xff}.${low >>> 8}.${low & 0xff}`; } export function classifyArticleAddress(rawAddress: string): ArticleAddressClass { const address = rawAddress.startsWith('[') && rawAddress.endsWith(']') ? rawAddress.slice(1, -1) : rawAddress; const family = isIP(address); if (family === 4) { const value = ipv4Number(address); if (value === undefined || ALWAYS_BLOCKED_IPV4.has(address)) return 'forbidden'; if ( inIpv4Cidr(value, '10.0.0.0', 8) || inIpv4Cidr(value, '100.64.0.0', 10) || inIpv4Cidr(value, '127.0.0.0', 8) || inIpv4Cidr(value, '172.16.0.0', 12) || inIpv4Cidr(value, '192.168.0.0', 16) ) { return 'private'; } if ( inIpv4Cidr(value, '0.0.0.0', 8) || inIpv4Cidr(value, '169.254.0.0', 16) || inIpv4Cidr(value, '192.0.0.0', 24) || inIpv4Cidr(value, '192.0.2.0', 24) || inIpv4Cidr(value, '198.18.0.0', 15) || inIpv4Cidr(value, '198.51.100.0', 24) || inIpv4Cidr(value, '203.0.113.0', 24) || inIpv4Cidr(value, '224.0.0.0', 4) || inIpv4Cidr(value, '240.0.0.0', 4) ) { return 'forbidden'; } return 'public'; } if (family !== 6) return 'forbidden'; const words = parseIpv6(address); if (words?.length !== 8) return 'forbidden'; const isAwsMetadataUla = words[0] === 0xfd00 && words[1] === 0x0ec2 && words[2] === 0 && words[3] === 0; const isGcpMetadataUla = words[0] === 0xfd20 && words[1] === 0x00ce && words[2] === 0 && words[3] === 0; if (isAwsMetadataUla || isGcpMetadataUla) { return 'forbidden'; } const firstSixZero = words.slice(0, 6).every((word) => word === 0); const firstFiveZero = words.slice(0, 5).every((word) => word === 0); if (firstFiveZero && words[5] === 0xffff) { return classifyArticleAddress(embeddedIpv4(words[6] ?? 0, words[7] ?? 0)); } if (firstSixZero) { if ((words[6] ?? 0) === 0 && (words[7] ?? 0) === 0) return 'forbidden'; if ((words[6] ?? 0) === 0 && (words[7] ?? 0) === 1) return 'private'; return classifyArticleAddress(embeddedIpv4(words[6] ?? 0, words[7] ?? 0)); } const isWellKnownNat64 = words[0] === 0x64 && words[1] === 0xff9b && words.slice(2, 6).every((word) => word === 0); const isLocalNat64 = words[0] === 0x64 && words[1] === 0xff9b && words[2] === 1 && words.slice(3, 6).every((word) => word === 0); if (isWellKnownNat64 || isLocalNat64) { return classifyArticleAddress(embeddedIpv4(words[6] ?? 0, words[7] ?? 0)); } if (words[0] === 0x2002) { return classifyArticleAddress(embeddedIpv4(words[1] ?? 0, words[2] ?? 0)); } const first = words[0] ?? 0; if ((first & 0xfe00) === 0xfc00) { return 'private'; } if ( (first & 0xffc0) === 0xfe80 || (first & 0xffc0) === 0xfec0 || (first & 0xff00) === 0xff00 || (first === 0x100 && words.slice(1, 4).every((word) => word === 0)) || (first === 0x2001 && words[1] === 0) || (first === 0x2001 && words[1] === 2) || (first === 0x2001 && words[1] === 0x0db8) || (first === 0x2001 && ((words[1] ?? 0) & 0xfff0) === 0x10) || (first === 0x2001 && ((words[1] ?? 0) & 0xfff0) === 0x20) ) { return 'forbidden'; } return (first & 0xe000) === 0x2000 ? 'public' : 'forbidden'; } export function allowPrivateArticleNetwork(explicit?: boolean): boolean { return explicit ?? process.env.RSS_ALLOW_PRIVATE_NETWORK === '1'; } function blockedDestination(hostname: string, address?: string): ArticleDestinationBlockedError { return new ArticleDestinationBlockedError( address ? `Article destination ${hostname} resolved to blocked address ${address}` : `Article destination blocked: ${hostname}`, ); } async function abortableLookup(promise: Promise, signal?: AbortSignal): Promise { signal?.throwIfAborted(); if (!signal) return promise; let onAbort: (() => void) | undefined; const aborted = new Promise((_resolve, reject) => { onAbort = () => reject(signal.reason); signal.addEventListener('abort', onAbort, { once: true }); }); try { return await Promise.race([promise, aborted]); } finally { // The one-shot listener is harmless after settlement, but explicitly // removing it avoids retaining a completed lookup on a long-lived signal. if (onAbort) signal.removeEventListener('abort', onAbort); } } const defaultResolver: ArticleDnsResolver = (hostname) => Bun.dns.lookup(hostname, { family: 0 }); export async function resolveArticleDestination( input: string | URL, options: { allowPrivateNetwork?: boolean; signal?: AbortSignal; resolver?: ArticleDnsResolver } = {}, ): Promise { const rawUrl = typeof input === 'string' ? input : input.toString(); if (rawUrl.length > MAX_HTTP_URL_SIZE) { throw new ArticleDestinationBlockedError( `Article destination URL is too long (max ${MAX_HTTP_URL_SIZE} characters)`, ); } const logicalUrl = new URL(rawUrl); if (logicalUrl.href.length > MAX_HTTP_URL_SIZE) { throw new ArticleDestinationBlockedError( `Article destination URL is too long (max ${MAX_HTTP_URL_SIZE} characters)`, ); } if (!['http:', 'https:'].includes(logicalUrl.protocol)) { throw new ArticleDestinationBlockedError('Article destination must use HTTP or HTTPS'); } if (logicalUrl.username || logicalUrl.password) { throw new ArticleDestinationBlockedError('Article destination must not include credentials'); } const bracketlessHostname = logicalUrl.hostname.replace(/^\[|\]$/g, ''); const hostname = bracketlessHostname.toLowerCase().replace(/\.$/, ''); const allowPrivateNetwork = allowPrivateArticleNetwork(options.allowPrivateNetwork); if (ALWAYS_BLOCKED_HOSTS.has(hostname)) throw blockedDestination(hostname); if (!allowPrivateNetwork && (hostname === 'localhost' || hostname.endsWith('.localhost'))) { throw blockedDestination(hostname); } const literalFamily = isIP(hostname); const answers: readonly ArticleDnsAnswer[] = literalFamily ? [{ address: hostname, family: literalFamily as 4 | 6 }] : await abortableLookup((options.resolver ?? defaultResolver)(hostname), options.signal); if (answers.length === 0) throw new ArticleDestinationBlockedError(`Article destination did not resolve: ${hostname}`); for (const answer of answers) { const classification = classifyArticleAddress(answer.address); if (classification === 'forbidden' || (classification === 'private' && !allowPrivateNetwork)) { throw blockedDestination(hostname, answer.address); } } const selected = answers[0]; if (!selected || isIP(selected.address) !== selected.family) { throw new ArticleDestinationBlockedError(`Article destination returned an invalid address: ${hostname}`); } const transportUrl = new URL(logicalUrl); transportUrl.hostname = selected.family === 6 ? `[${selected.address}]` : selected.address; return { logicalUrl, transportUrl, address: selected.address, family: selected.family, hostHeader: logicalUrl.host, tlsServerName: literalFamily === 0 ? hostname : undefined, }; }