// Wraps fetch with SSRF mitigations: // - http/https only // - DNS-resolve host and reject loopback/RFC1918/link-local/CGNAT/zero // - Pin the resolved IP into the undici dispatcher so a rebind between // our validation lookup and the TCP connect cannot point us at an // internal address (TOCTOU defeat). // - Follow redirects manually with the same validation on each hop. // // Defaults can be loosened via VOID_INGEST_ALLOW_PRIVATE=true for tests // that hit 127.0.0.1 fixtures. import { lookup } from 'node:dns/promises'; import net from 'node:net'; import { Agent } from 'undici'; const BLOCK_V4 = [ ['0.0.0.0', 8], ['127.0.0.0', 8], ['10.0.0.0', 8], ['172.16.0.0', 12], ['192.168.0.0', 16], ['169.254.0.0', 16], ['100.64.0.0', 10] ]; function ipv4ToInt(ip) { return ip.split('.').reduce((acc, oct) => (acc << 8) + Number(oct), 0) >>> 0; } function inV4Cidr(ip, [cidrIp, bits]) { const ipi = ipv4ToInt(ip); const cidri = ipv4ToInt(cidrIp); const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0; return (ipi & mask) === (cidri & mask); } function isBlockedAddr(addr) { if (process.env.VOID_INGEST_ALLOW_PRIVATE === 'true') return false; if (net.isIPv4(addr)) return BLOCK_V4.some(c => inV4Cidr(addr, c)); if (net.isIPv6(addr)) { const a = addr.toLowerCase(); if (a === '::1' || a === '::' ) return true; if (a.startsWith('fc') || a.startsWith('fd')) return true; // ULA if (a.startsWith('fe80')) return true; // link-local if (a.startsWith('::ffff:')) { const v4 = a.slice(7); if (net.isIPv4(v4)) return isBlockedAddr(v4); } } return false; } export class SafeFetchError extends Error { constructor(message, code) { super(message); this.code = code; } } async function resolveAndCheck(host) { const records = await lookup(host, { all: true }); if (!records.length) throw new SafeFetchError(`no DNS for ${host}`, 'no_dns'); for (const r of records) { if (isBlockedAddr(r.address)) { throw new SafeFetchError(`${host} resolves to blocked address ${r.address}`, 'blocked_addr'); } } return records[0]; } function pinnedDispatcher(address, family) { return new Agent({ connect: { // undici will call our lookup instead of OS DNS, so the chosen IP // is the one we already validated. No TOCTOU re-resolution. lookup: (_hostname, _options, cb) => cb(null, address, family) } }); } export async function safeFetch(url, options = {}, { maxHops = 5 } = {}) { let current = url; for (let hop = 0; hop <= maxHops; hop++) { const u = new URL(current); if (u.protocol !== 'http:' && u.protocol !== 'https:') { throw new SafeFetchError(`unsupported scheme ${u.protocol}`, 'scheme'); } let address, family; if (net.isIP(u.hostname)) { if (isBlockedAddr(u.hostname)) throw new SafeFetchError(`blocked literal IP ${u.hostname}`, 'blocked_addr'); address = u.hostname; family = net.isIPv4(u.hostname) ? 4 : 6; } else { const rec = await resolveAndCheck(u.hostname); address = rec.address; family = rec.family; } const dispatcher = pinnedDispatcher(address, family); const res = await fetch(current, { ...options, redirect: 'manual', dispatcher }); if ([301,302,303,307,308].includes(res.status)) { const loc = res.headers.get('location'); if (!loc) throw new SafeFetchError('redirect without Location', 'bad_redirect'); current = new URL(loc, current).toString(); continue; } return res; } throw new SafeFetchError(`too many redirects (max ${maxHops})`, 'too_many_redirects'); }