import { createHash } from 'node:crypto'; import { lookup } from 'node:dns/promises'; import { once } from 'node:events'; import type { ServerResponse } from 'node:http'; import { isIP } from 'node:net'; const DEFAULT_MAX_BYTES = 64 * 1024 * 1024; const DEFAULT_TIMEOUT_MS = 15_000; const DEFAULT_MAX_REDIRECTS = 3; export interface ReviewUiMediaProxyDependencies { fetchImpl?: (url: URL, init: RequestInit) => Promise; lookupAll?: (hostname: string) => Promise>; maxBytes?: number; timeoutMs?: number; maxRedirects?: number; } function remoteMediaId(url: string): string { return createHash('sha256').update(url).digest('base64url'); } function collectRemoteMediaUrls(value: unknown, urls: Set, seen = new Set()): void { if (typeof value === 'string') { try { const parsed = new URL(value); if (parsed.protocol === 'http:' || parsed.protocol === 'https:') urls.add(parsed.href); } catch { // Local paths and prose are intentionally ignored. } return; } if (!value || typeof value !== 'object' || seen.has(value)) return; seen.add(value); if (Array.isArray(value)) { for (const entry of value) collectRemoteMediaUrls(entry, urls, seen); return; } for (const entry of Object.values(value as Record)) collectRemoteMediaUrls(entry, urls, seen); } export function buildRemoteMediaIds(...values: unknown[]): Record { const urls = new Set(); for (const value of values) collectRemoteMediaUrls(value, urls); return Object.fromEntries([...urls].sort().map((url) => [url, remoteMediaId(url)])); } function blockedIpv4Address(address: string): boolean { const octets = address.split('.').map(Number); if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return true; const [a, b] = octets as [number, number, number, number]; return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && (b === 0 || b === 168)) || (a === 198 && (b === 18 || b === 19 || b === 51)) || (a === 203 && b === 0) || a >= 224; } export function isBlockedReviewMediaAddress(address: string): boolean { const normalized = address.trim().toLowerCase().split('%')[0] ?? ''; const family = isIP(normalized); if (family === 4) return blockedIpv4Address(normalized); if (family !== 6) return true; if (normalized.startsWith('::ffff:')) { const mapped = normalized.slice('::ffff:'.length); return isIP(mapped) !== 4 || blockedIpv4Address(mapped); } if (normalized === '::' || normalized === '::1') return true; if (/^f[cd]/.test(normalized) || /^fe[89ab]/.test(normalized) || normalized.startsWith('ff')) return true; if (normalized.startsWith('2001:db8:')) return true; return !/^[23]/.test(normalized); } async function assertPublicUrl( url: URL, lookupAll: NonNullable, ): Promise { if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('Unsupported media URL protocol'); if (url.username || url.password) throw new Error('Credentialed media URLs are not supported'); const addresses = await lookupAll(url.hostname); if (!addresses.length || addresses.some(({ address }) => isBlockedReviewMediaAddress(address))) { throw new Error('Media URL resolves to a private or special-use address'); } } async function fetchFollowingPublicRedirects( startUrl: URL, signal: AbortSignal, dependencies: Required>, ): Promise { let current = startUrl; for (let redirects = 0; redirects <= dependencies.maxRedirects; redirects += 1) { await assertPublicUrl(current, dependencies.lookupAll); const upstream = await dependencies.fetchImpl(current, { redirect: 'manual', signal }); if (![301, 302, 303, 307, 308].includes(upstream.status)) return upstream; if (redirects === dependencies.maxRedirects) throw new Error('Media URL exceeded the redirect limit'); const location = upstream.headers.get('location'); if (!location) throw new Error('Media redirect did not include a location'); current = new URL(location, current); } throw new Error('Media URL exceeded the redirect limit'); } function sendText(response: ServerResponse, statusCode: number, body: string): void { response.statusCode = statusCode; response.setHeader('Content-Type', 'text/plain; charset=utf-8'); response.setHeader('Cache-Control', 'no-store'); response.end(`${body}\n`); } export async function sendProxiedMedia( response: ServerResponse, rawUrl: string | null, overrides: ReviewUiMediaProxyDependencies = {}, ): Promise { const mediaUrl = rawUrl?.trim(); if (!mediaUrl) { sendText(response, 404, 'Authorized media was not found in the project inventory'); return; } let parsed: URL; try { parsed = new URL(mediaUrl); } catch { sendText(response, 400, 'Invalid media URL'); return; } const fetchImpl = overrides.fetchImpl ?? ((url: URL, init: RequestInit) => fetch(url, init)); const lookupAll = overrides.lookupAll ?? ((hostname: string) => lookup(hostname, { all: true, verbatim: true })); const maxBytes = overrides.maxBytes ?? DEFAULT_MAX_BYTES; const timeoutMs = overrides.timeoutMs ?? DEFAULT_TIMEOUT_MS; const maxRedirects = overrides.maxRedirects ?? DEFAULT_MAX_REDIRECTS; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const upstream = await fetchFollowingPublicRedirects(parsed, controller.signal, { fetchImpl, lookupAll, maxRedirects }); if (!upstream.ok) { sendText(response, upstream.status, `Could not fetch media: ${upstream.statusText}`); return; } const upstreamType = upstream.headers.get('content-type') ?? 'application/octet-stream'; if (!/^(image|video)\//i.test(upstreamType)) { await upstream.body?.cancel(); sendText(response, 415, 'Media proxy only supports image and video responses'); return; } const declaredLength = Number(upstream.headers.get('content-length')); if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { await upstream.body?.cancel(); sendText(response, 413, `Media response exceeds the ${maxBytes}-byte proxy limit`); return; } if (!upstream.body) { sendText(response, 502, 'Media response did not include a body'); return; } response.writeHead(200, { 'Content-Type': upstreamType, 'Cache-Control': 'private, max-age=3600', ...(Number.isFinite(declaredLength) && declaredLength >= 0 ? { 'Content-Length': String(declaredLength) } : {}), }); let receivedBytes = 0; for await (const chunk of upstream.body) { const buffer = Buffer.from(chunk); receivedBytes += buffer.byteLength; if (receivedBytes > maxBytes) { controller.abort(); response.destroy(new Error(`Media response exceeded the ${maxBytes}-byte proxy limit`)); return; } if (!response.write(buffer)) await once(response, 'drain'); } response.end(); } catch (error) { if (!response.headersSent) { const timedOut = controller.signal.aborted; sendText(response, timedOut ? 504 : 502, timedOut ? 'Media proxy request timed out' : `Could not fetch authorized media: ${error instanceof Error ? error.message : String(error)}`); } else { response.destroy(); } } finally { clearTimeout(timeout); } }