import { promises as dns, type LookupAddress } from 'node:dns'; import { isIP, type LookupFunction } from 'node:net'; import { Agent, fetch as undiciFetch, type BodyInit as UndiciBodyInit, type Response as UndiciResponse, } from 'undici'; import { isGlobalUnicastIP } from '@/lib/server/ssrf-guard'; export const PUBLIC_MEDIA_MAX_RESPONSE_BYTES = 25 * 1024 * 1024; export const PUBLIC_URL_MAX_REQUEST_BYTES = 50 * 1024 * 1024; export type PublicUrlFetchErrorCode = | 'INVALID_URL' | 'BLOCKED_TARGET' | 'DNS_FAILURE' | 'REQUEST_TOO_LARGE' | 'UPSTREAM_FAILURE'; /** An intentionally non-sensitive error suitable for mapping at an API boundary. */ export class PublicUrlFetchError extends Error { constructor( readonly code: PublicUrlFetchErrorCode, message: string, ) { super(message); this.name = 'PublicUrlFetchError'; } } interface PinnedAddress { address: string; family: 4 | 6; } export interface ResolvedPublicTarget { url: URL; hostname: string; addresses: readonly PinnedAddress[]; } export interface PinnedPublicResponse { response: UndiciResponse; dispose: () => Promise; } export type PinnedPublicRequestBody = string | ArrayBuffer | NodeJS.ArrayBufferView | Blob; export interface FetchPinnedPublicUrlOptions { signal?: AbortSignal; maxResponseBytes?: number; maxRequestBytes?: number; headersTimeoutMs?: number; bodyTimeoutMs?: number; headers?: Readonly>; method?: 'GET' | 'PUT'; body?: PinnedPublicRequestBody; } function normalizeHostname(value: string): string { let hostname = value.trim().toLowerCase(); if (hostname.startsWith('[') && hostname.endsWith(']')) { hostname = hostname.slice(1, -1); } return hostname.replace(/\.+$/, ''); } function blockedTarget(): never { throw new PublicUrlFetchError( 'BLOCKED_TARGET', 'URL target is not an allowed public network address', ); } /** * Resolve a URL exactly once and retain only the verified address set. * * This public-proxy policy intentionally ignores ALLOW_LOCAL_NETWORKS. That * switch is useful for explicitly configured self-hosted providers, but must * never turn a user-supplied media URL into a general-purpose intranet proxy. */ export async function resolvePublicTarget(rawUrl: string): Promise { let url: URL; try { url = new URL(rawUrl); } catch { throw new PublicUrlFetchError('INVALID_URL', 'Invalid public URL'); } if (url.protocol !== 'http:' && url.protocol !== 'https:') { throw new PublicUrlFetchError('INVALID_URL', 'Only HTTP(S) public URLs are allowed'); } if (url.username || url.password) { throw new PublicUrlFetchError('INVALID_URL', 'Credentialed URLs are not allowed'); } const hostname = normalizeHostname(url.hostname); if (!hostname) { throw new PublicUrlFetchError('INVALID_URL', 'Invalid public URL hostname'); } const literalFamily = isIP(hostname); if (literalFamily) { if (!isGlobalUnicastIP(hostname)) blockedTarget(); return { url, hostname, addresses: [{ address: hostname, family: literalFamily as 4 | 6 }], }; } let answers: LookupAddress[]; try { answers = await dns.lookup(hostname, { all: true, verbatim: true }); } catch { throw new PublicUrlFetchError('DNS_FAILURE', 'Unable to resolve a public URL target'); } if (answers.length === 0) { throw new PublicUrlFetchError('DNS_FAILURE', 'Unable to resolve a public URL target'); } const addresses: PinnedAddress[] = []; const seen = new Set(); for (const answer of answers) { const family = isIP(answer.address); if ((family !== 4 && family !== 6) || !isGlobalUnicastIP(answer.address)) { blockedTarget(); } const key = `${family}:${answer.address.toLowerCase()}`; if (!seen.has(key)) { seen.add(key); addresses.push({ address: answer.address, family }); } } if (addresses.length === 0) blockedTarget(); return { url, hostname, addresses }; } function lookupError(code: string): NodeJS.ErrnoException { return Object.assign(new Error('Pinned public address is unavailable'), { code }); } function requestBodyByteLength(body: PinnedPublicRequestBody): number { if (typeof body === 'string') return Buffer.byteLength(body); if (body instanceof ArrayBuffer) return body.byteLength; if (ArrayBuffer.isView(body)) return body.byteLength; return body.size; } function positiveLimit(value: number | undefined, fallback: number): number { if (value === undefined) return fallback; if (Number.isSafeInteger(value) && value > 0) return value; throw new PublicUrlFetchError('INVALID_URL', 'Fetch limits must be positive integers'); } /** * A connector lookup that never calls DNS. It may only return addresses from * the set resolved and checked by `resolvePublicTarget`. */ export function createPinnedLookup(target: ResolvedPublicTarget): LookupFunction { const pinned = target.addresses.map((entry) => ({ ...entry })); return (requestedHostname, options, callback) => { if (normalizeHostname(requestedHostname) !== target.hostname) { callback(lookupError('ENOTFOUND'), options.all ? [] : '', 0); return; } const requestedFamily = options.family === 4 || options.family === 6 ? options.family : 0; const candidates = requestedFamily ? pinned.filter(({ family }) => family === requestedFamily) : pinned; if (candidates.length === 0) { callback(lookupError('EAFNOSUPPORT'), options.all ? [] : '', 0); return; } if (options.all) { callback(null, candidates); return; } callback(null, candidates[0].address, candidates[0].family); }; } /** * Perform one redirect-disabled fetch through a DNS-pinned dispatcher. * Redirect handling belongs to the caller so every hop receives a fresh * resolution, global-unicast check and independently pinned connection. */ export async function fetchPinnedPublicUrl( rawUrl: string, options: FetchPinnedPublicUrlOptions = {}, ): Promise { const method: string = options.method ?? 'GET'; if (method !== 'GET' && method !== 'PUT') { throw new PublicUrlFetchError('INVALID_URL', 'Unsupported public URL request method'); } if (method === 'GET' && options.body !== undefined) { throw new PublicUrlFetchError('INVALID_URL', 'GET requests cannot include a request body'); } const managedHeaders = new Set(['host', 'content-length', 'transfer-encoding']); if (Object.keys(options.headers ?? {}).some((name) => managedHeaders.has(name.toLowerCase()))) { throw new PublicUrlFetchError('INVALID_URL', 'Transport-managed headers cannot be overridden'); } if (options.body !== undefined) { const maxRequestBytes = positiveLimit(options.maxRequestBytes, PUBLIC_URL_MAX_REQUEST_BYTES); if (requestBodyByteLength(options.body) > maxRequestBytes) { throw new PublicUrlFetchError('REQUEST_TOO_LARGE', 'Public URL request body is too large'); } } const target = await resolvePublicTarget(rawUrl); const dispatcher = new Agent({ connect: { lookup: createPinnedLookup(target), autoSelectFamily: true, autoSelectFamilyAttemptTimeout: 250, }, connectTimeout: 10_000, headersTimeout: positiveLimit(options.headersTimeoutMs, 15_000), bodyTimeout: positiveLimit(options.bodyTimeoutMs, 30_000), maxResponseSize: positiveLimit(options.maxResponseBytes, PUBLIC_MEDIA_MAX_RESPONSE_BYTES), }); let response: UndiciResponse; try { response = await undiciFetch(target.url, { method, redirect: 'manual', dispatcher, signal: options.signal, headers: options.headers ? { ...options.headers } : undefined, body: options.body as UndiciBodyInit | undefined, }); } catch { await dispatcher.close().catch(() => {}); throw new PublicUrlFetchError('UPSTREAM_FAILURE', 'Unable to fetch upstream media'); } let disposed = false; return { response, dispose: async () => { if (disposed) return; disposed = true; if (response.body && !response.bodyUsed) { await response.body.cancel().catch(() => {}); } await dispatcher.close().catch(() => {}); }, }; }