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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,17 @@
|
|||||||
// Wraps fetch with SSRF mitigations:
|
// Wraps fetch with SSRF mitigations:
|
||||||
// - http/https only
|
// - http/https only
|
||||||
// - DNS-resolve host and reject loopback/RFC1918/link-local/CGNAT/zero
|
// - DNS-resolve host and reject loopback/RFC1918/link-local/CGNAT/zero
|
||||||
// - Pin the resolved IP into the request so a rebind between resolve and
|
// - Pin the resolved IP into the undici dispatcher so a rebind between
|
||||||
// connect cannot redirect to an internal address
|
// our validation lookup and the TCP connect cannot point us at an
|
||||||
// - Follow redirects manually with the same validation on each hop
|
// 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
|
// Defaults can be loosened via VOID_INGEST_ALLOW_PRIVATE=true for tests
|
||||||
// against fixtures that hit 127.0.0.1).
|
// that hit 127.0.0.1 fixtures.
|
||||||
|
|
||||||
import { lookup } from 'node:dns/promises';
|
import { lookup } from 'node:dns/promises';
|
||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
import { Agent } from 'node:https';
|
import { Agent } from 'undici';
|
||||||
|
|
||||||
const BLOCK_V4 = [
|
const BLOCK_V4 = [
|
||||||
['0.0.0.0', 8],
|
['0.0.0.0', 8],
|
||||||
@@ -64,6 +65,16 @@ async function resolveAndCheck(host) {
|
|||||||
return records[0];
|
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 } = {}) {
|
export async function safeFetch(url, options = {}, { maxHops = 5 } = {}) {
|
||||||
let current = url;
|
let current = url;
|
||||||
for (let hop = 0; hop <= maxHops; hop++) {
|
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:') {
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
||||||
throw new SafeFetchError(`unsupported scheme ${u.protocol}`, 'scheme');
|
throw new SafeFetchError(`unsupported scheme ${u.protocol}`, 'scheme');
|
||||||
}
|
}
|
||||||
|
let address, family;
|
||||||
if (net.isIP(u.hostname)) {
|
if (net.isIP(u.hostname)) {
|
||||||
if (isBlockedAddr(u.hostname)) throw new SafeFetchError(`blocked literal IP ${u.hostname}`, 'blocked_addr');
|
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 {
|
} 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)) {
|
if ([301,302,303,307,308].includes(res.status)) {
|
||||||
const loc = res.headers.get('location');
|
const loc = res.headers.get('location');
|
||||||
if (!loc) throw new SafeFetchError('redirect without Location', 'bad_redirect');
|
if (!loc) throw new SafeFetchError('redirect without Location', 'bad_redirect');
|
||||||
|
|||||||
Reference in New Issue
Block a user