cb(null, address, family) was returning Invalid IP address: undefined
under undici v6. Returning the full records array (each {address, family})
gives undici what it expects and lets it pick the best family.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
105 lines
3.6 KiB
JavaScript
105 lines
3.6 KiB
JavaScript
// 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; // pass all validated records to the dispatcher
|
|
}
|
|
|
|
function pinnedDispatcher(records) {
|
|
return new Agent({
|
|
connect: {
|
|
// undici will call our lookup instead of OS DNS, so it only sees
|
|
// IPs we've already validated. The array form `(err, results)` is
|
|
// what undici v6+ expects (results[i] = {address, family}).
|
|
lookup: (_hostname, _options, cb) => cb(null, records)
|
|
}
|
|
});
|
|
}
|
|
|
|
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 records;
|
|
if (net.isIP(u.hostname)) {
|
|
if (isBlockedAddr(u.hostname)) throw new SafeFetchError(`blocked literal IP ${u.hostname}`, 'blocked_addr');
|
|
records = [{ address: u.hostname, family: net.isIPv4(u.hostname) ? 4 : 6 }];
|
|
} else {
|
|
records = await resolveAndCheck(u.hostname);
|
|
}
|
|
const dispatcher = pinnedDispatcher(records);
|
|
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');
|
|
}
|