From 99ab1ffb70814461e928a12d043f1e11344a04ed Mon Sep 17 00:00:00 2001 From: root Date: Mon, 1 Jun 2026 03:48:52 +1000 Subject: [PATCH] fix(ingest): pin resolved IP into safe_fetch to defeat DNS-rebinding Replaces the validate-then-call-fetch pattern (which left a TOCTOU window where the OS resolver could return a different IP at connect time) with an undici Agent dispatcher whose lookup() returns the IP we already validated. Same hardening on every redirect hop. Co-Authored-By: Claude Opus 4.7 --- lib/ingest/safe_fetch.js | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/lib/ingest/safe_fetch.js b/lib/ingest/safe_fetch.js index c537cf9..e3b4d6b 100644 --- a/lib/ingest/safe_fetch.js +++ b/lib/ingest/safe_fetch.js @@ -1,16 +1,17 @@ // 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 request so a rebind between resolve and -// connect cannot redirect to an internal address -// - Follow redirects manually with the same validation on each hop +// - 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 dev/test -// against fixtures that hit 127.0.0.1). +// 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 'node:https'; +import { Agent } from 'undici'; const BLOCK_V4 = [ ['0.0.0.0', 8], @@ -64,6 +65,16 @@ async function resolveAndCheck(host) { 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++) { @@ -71,12 +82,18 @@ export async function safeFetch(url, options = {}, { maxHops = 5 } = {}) { 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 { - await resolveAndCheck(u.hostname); + const rec = await resolveAndCheck(u.hostname); + address = rec.address; + family = rec.family; } - const res = await fetch(current, { ...options, redirect: 'manual' }); + 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');