import { lookup } from "node:dns/promises"; import net from "node:net"; export function combinedSignal(timeoutMs: number, signal?: AbortSignal): AbortSignal { const timeoutSignal = AbortSignal.timeout(timeoutMs); return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; } function isPrivateIp(address: string): boolean { if (net.isIPv4(address)) { const [a, b] = address.split(".").map(Number); return ( a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a >= 224 ); } const norm = address.toLowerCase(); return ( norm === "::" || norm === "::1" || norm.startsWith("fc") || norm.startsWith("fd") || /^fe[89ab]/.test(norm) || norm.startsWith("::ffff:127.") || norm.startsWith("::ffff:10.") || norm.startsWith("::ffff:192.168.") ); } export async function validateRemoteUrl(raw: string): Promise { const url = new URL(raw); if (url.protocol !== "http:" && url.protocol !== "https:") { throw new Error("Only http(s) URLs are supported for remote images."); } if (url.username || url.password) { throw new Error("Image URL must not contain username or password."); } const hostname = url.hostname.replace(/^\[|\]$/g, "").toLowerCase(); if (hostname === "localhost" || hostname.endsWith(".localhost")) { throw new Error(`Access to local address is blocked: ${hostname}`); } const addresses = net.isIP(hostname) ? [{ address: hostname }] : await lookup(hostname, { all: true, verbatim: true }); if (!addresses.length || addresses.some(({ address }) => isPrivateIp(address))) { throw new Error(`Access to private or local network address is blocked: ${hostname}`); } return url; } export async function readBounded(response: Response, maxBytes: number): Promise { const declared = Number(response.headers.get("content-length")); if (Number.isFinite(declared) && declared > maxBytes) { throw new Error(`Content exceeds ${Math.round(maxBytes / (1024 * 1024))} MiB limit.`); } if (!response.body) return Buffer.alloc(0); const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let total = 0; try { while (true) { const { done, value } = await reader.read(); if (done) break; if (!value) continue; total += value.byteLength; if (total > maxBytes) { await reader.cancel(); throw new Error(`Content exceeds ${Math.round(maxBytes / (1024 * 1024))} MiB limit.`); } chunks.push(value); } } finally { reader.releaseLock(); } return Buffer.concat(chunks, total); }