Skip to content

Commit 80e78d8

Browse files
feat(protect): DNS-rebinding resistance for egress (Node http path)
The egress guard screened outbound calls by hostname, so a name that passes the check but resolves to an internal/metadata IP (DNS rebinding, or just a hostname pointing inward) slipped through. On the Node http/https path, inject a screening DNS `lookup` into the request options: resolve the hostname, run each resolved address through the same egress predicate (so `internal_host` rules + allowlist apply to the IP too), block if any is disallowed, and pin the connection to the vetted resolution — closing the time-of-check/time-of-use gap. Only for real hostnames (literal IPs are already covered by the sync check). Default on via `screenDns` (disable with `screenDns: false`); needs node:dns/net, so it's a no-op on edge runtimes, where hostname rules still apply. Fail-open: a resolver or injection error proceeds unscreened rather than breaking the request. `lookup` is injectable for tests. The fetch/undici path is intentionally left for a follow-up. +5 tests, 495 total, typecheck (incl. template check) + build clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9ccb288 commit 80e78d8

4 files changed

Lines changed: 215 additions & 6 deletions

File tree

‎src/protect/egress.js‎

Lines changed: 100 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77

88
/**
99
* @param {{ shouldBlock: (url:string, host:string|null, method:string)=>boolean,
10-
* onBlock?: (info:{url:string,host:string|null,method:string})=>void }} opts
10+
* onBlock?: (info:{url:string,host:string|null,method:string})=>void,
11+
* dnsScreen?: boolean,
12+
* lookup?: Function }} opts
1113
* @returns {Promise<() => void>} uninstall (restores every patched surface)
1214
*/
13-
export async function installEgressGuard({ shouldBlock, onBlock } = {}) {
15+
export async function installEgressGuard({ shouldBlock, onBlock, dnsScreen = true, lookup } = {}) {
1416
const restores = [];
1517
if (typeof shouldBlock !== 'function') return () => {};
1618

@@ -44,11 +46,29 @@ export async function installEgressGuard({ shouldBlock, onBlock } = {}) {
4446
});
4547
}
4648

47-
// 2. node:http / node:https — best-effort; absent on Workers/Deno-without-node (import throws).
49+
// DNS-rebinding screen for the Node http path: instead of trusting the hostname, resolve it
50+
// ourselves, block if it maps to a disallowed address, and PIN the connection to that vetted
51+
// resolution — so a name that passes the hostname check but resolves (or re-resolves) to an
52+
// internal/metadata IP can't slip through (time-of-check vs time-of-use). Needs node:dns +
53+
// node:net; absent on edge runtimes, where the hostname rules still apply.
54+
let screen = null;
55+
if (dnsScreen) {
56+
try {
57+
const resolveLookup = lookup ?? (await import('node:dns')).lookup;
58+
const { isIP } = await import('node:net');
59+
if (typeof resolveLookup === 'function' && typeof isIP === 'function') {
60+
screen = { lookup: resolveLookup, isIP };
61+
}
62+
} catch {
63+
screen = null; // no node:dns/net here — skip, hostname rules still apply
64+
}
65+
}
66+
67+
// node:http / node:https — best-effort; absent on Workers/Deno-without-node (import throws).
4868
for (const moduleName of ['node:http', 'node:https']) {
4969
try {
5070
const mod = await import(moduleName);
51-
const restore = patchHttpModule(mod.default ?? mod, block);
71+
const restore = patchHttpModule(mod.default ?? mod, block, screen);
5272
if (restore) restores.push(restore);
5373
} catch {
5474
/* module not available on this runtime — skip */
@@ -93,7 +113,7 @@ export async function installEgressGuard({ shouldBlock, onBlock } = {}) {
93113
}
94114

95115
// Wrap http(s).request/get so a blocked destination throws before the socket opens.
96-
function patchHttpModule(http, block) {
116+
function patchHttpModule(http, block, screen) {
97117
if (!http || typeof http.request !== 'function' || http.__patchstackGuarded) return null;
98118
const originalRequest = http.request;
99119
const originalGet = http.get;
@@ -104,6 +124,14 @@ function patchHttpModule(http, block) {
104124
if (target && block(target.url, target.host, target.method)) {
105125
throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host ?? target.url}`);
106126
}
127+
// DNS screen: only for real hostnames (a literal IP was already covered by the check above).
128+
if (target && screen && target.host && screen.isIP(target.host) === 0) {
129+
try {
130+
args = withScreeningLookup(args, target, block, screen.lookup);
131+
} catch {
132+
/* injection failed — proceed unscreened (fail-open) */
133+
}
134+
}
107135
return original.apply(this, args);
108136
};
109137

@@ -150,3 +178,70 @@ function normalizeHost(raw) {
150178
if ((host.match(/:/g) || []).length > 1) return host; // bare IPv6 — no host:port to split
151179
return host.split(':')[0];
152180
}
181+
182+
// Given the addresses a hostname resolved to, return the first one the policy blocks (else null).
183+
// Reuses the same `block` predicate as the hostname check, so egress rules + allowlist apply to
184+
// the resolved IP too. Exported for tests.
185+
export function screenResolved(addresses, target, block) {
186+
for (const a of addresses || []) {
187+
const ip = a && typeof a === 'object' ? a.address : a;
188+
if (ip && block(target.url, ip, target.method)) return ip;
189+
}
190+
return null;
191+
}
192+
193+
// Build a DNS `lookup` that screens every resolved address before the socket connects, then hands
194+
// back the vetted addresses (pinning the connection to what we checked). A blocked address errors
195+
// the connection; a resolver error or our own failure falls through to normal resolution (fail-open).
196+
function withScreeningLookup(args, target, block, lookup) {
197+
const screeningLookup = (hostname, options, callback) => {
198+
let opts = options;
199+
let cb = callback;
200+
if (typeof opts === 'function') {
201+
cb = opts;
202+
opts = {};
203+
}
204+
if (!opts || typeof opts !== 'object') opts = {};
205+
try {
206+
lookup(hostname, { ...opts, all: true }, (err, addresses) => {
207+
if (err) return cb(err);
208+
const list = Array.isArray(addresses) ? addresses : [];
209+
const blocked = screenResolved(list, target, block);
210+
if (blocked) {
211+
return cb(new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host} resolved to ${blocked}`));
212+
}
213+
if (opts.all) return cb(null, list);
214+
const first = list[0];
215+
if (!first) return cb(new Error(`Patchstack: could not resolve ${hostname}`));
216+
return cb(null, first.address, first.family);
217+
});
218+
} catch {
219+
// Our screening threw — fall back to a plain resolution so we never break a request ourselves.
220+
try {
221+
lookup(hostname, opts, cb);
222+
} catch {
223+
cb(new Error(`Patchstack: lookup failed for ${hostname}`));
224+
}
225+
}
226+
};
227+
return injectLookupOption(args, screeningLookup);
228+
}
229+
230+
// Return a new args array for http(s).request with our `lookup` set on the options object (cloned,
231+
// never mutating the caller's object), inserting an options object when the call didn't pass one.
232+
function injectLookupOption(args, lookup) {
233+
const first = args[0];
234+
if (first && typeof first === 'object' && !(first instanceof URL)) {
235+
return [{ ...first, lookup }, ...args.slice(1)];
236+
}
237+
const rest = args.slice(1);
238+
const optIdx = rest.findIndex((a) => a && typeof a === 'object' && !(a instanceof URL));
239+
if (optIdx !== -1) {
240+
const next = [...rest];
241+
next[optIdx] = { ...rest[optIdx], lookup };
242+
return [first, ...next];
243+
}
244+
const cbIdx = rest.findIndex((a) => typeof a === 'function');
245+
if (cbIdx === -1) return [first, { lookup }, ...rest];
246+
return [first, ...rest.slice(0, cbIdx), { lookup }, ...rest.slice(cbIdx)];
247+
}

‎src/protect/protect.d.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ export interface CreateProtectionOptions {
5555
egress?: boolean;
5656
/** Hosts exempt from egress screening. */
5757
allowHosts?: string[];
58+
/**
59+
* Screen the Node http/https path against DNS rebinding: resolve outbound hostnames and block +
60+
* pin to the vetted address when they map to a disallowed (internal/metadata) IP. Default true;
61+
* only active when `egress` is on and node:dns is available (a no-op on edge runtimes).
62+
*/
63+
screenDns?: boolean;
5864
/** Redaction mask (string or per-category function). Default "[REDACTED]". */
5965
maskWith?: string | ((category?: string) => string);
6066
onError?: (err: unknown) => void;

‎src/protect/runtime.js‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,11 @@ export async function createProtection(options = {}) {
358358

359359
// Egress interception is opt-in (it wraps the global fetch, and node:http/https on Node).
360360
if (options.egress) {
361-
protection.uninstallEgress = await installEgressGuard({ shouldBlock: egressShouldBlock, onBlock: options.onEgressBlock });
361+
protection.uninstallEgress = await installEgressGuard({
362+
shouldBlock: egressShouldBlock,
363+
onBlock: options.onEgressBlock,
364+
dnsScreen: options.screenDns !== false,
365+
});
362366
}
363367

364368
return protection;

‎tests/protect/egress-dns.test.ts‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { afterEach, describe, expect, it } from 'vitest';
2+
import { installEgressGuard, screenResolved } from '../../src/protect/egress.js';
3+
4+
// A hostname that passes the name check but resolves to an internal/metadata IP must be blocked
5+
// (DNS rebinding). We inject a fake resolver so the tests are deterministic — no real DNS.
6+
7+
const isInternal = (host: string) => /^(?:127\.|10\.|169\.254\.|192\.168\.)/.test(String(host)) || host === '::1';
8+
const shouldBlock = (_url: string, host: string | null) => (host ? isInternal(host) : false);
9+
const target = { url: 'http://rebind.test/', host: 'rebind.test', method: 'GET' };
10+
const nodeHttp = async (): Promise<any> => {
11+
const ns: any = await import('node:http');
12+
return ns.default ?? ns;
13+
};
14+
15+
let restore: (() => void) | undefined;
16+
afterEach(() => {
17+
restore?.();
18+
restore = undefined;
19+
});
20+
21+
describe('screenResolved', () => {
22+
it('flags the first internal address a hostname resolves to', () => {
23+
const hit = screenResolved([{ address: '93.184.216.34' }, { address: '169.254.169.254' }], target, shouldBlock);
24+
expect(hit).toBe('169.254.169.254');
25+
});
26+
27+
it('returns null when every resolved address is public', () => {
28+
expect(screenResolved([{ address: '93.184.216.34' }, { address: '1.1.1.1' }], target, shouldBlock)).toBeNull();
29+
});
30+
});
31+
32+
describe('egress DNS-rebinding screen (node:http)', () => {
33+
it('blocks a hostname that resolves to an internal address', async () => {
34+
restore = await installEgressGuard({
35+
shouldBlock,
36+
lookup: (_h: string, _o: any, cb: any) => cb(null, [{ address: '169.254.169.254', family: 4 }]),
37+
});
38+
const http = await nodeHttp();
39+
const err: any = await new Promise((resolve) => {
40+
const req = http.request('http://rebind.test/');
41+
req.on('error', resolve);
42+
req.end();
43+
});
44+
expect(String(err)).toMatch(/Patchstack blocked/);
45+
expect(String(err)).toMatch(/169\.254\.169\.254/); // reports what it resolved to
46+
});
47+
48+
it('allows and pins a hostname that resolves to a permitted address', async () => {
49+
const http = await nodeHttp();
50+
const server = http.createServer((_req: any, res: any) => res.end('ok'));
51+
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
52+
const { port } = server.address();
53+
try {
54+
// 127.* is permitted by this predicate, so the pinned resolution reaches the local server.
55+
restore = await installEgressGuard({
56+
shouldBlock: (_url: string, host: string | null) => /^(?:169\.254\.|10\.|192\.168\.)/.test(String(host)),
57+
lookup: (_h: string, _o: any, cb: any) => cb(null, [{ address: '127.0.0.1', family: 4 }]),
58+
});
59+
const body: string = await new Promise((resolve, reject) => {
60+
const req = http.request({ hostname: 'public.test', port, path: '/' }, (res: any) => {
61+
let data = '';
62+
res.on('data', (c: Buffer) => (data += c));
63+
res.on('end', () => resolve(data));
64+
});
65+
req.on('error', reject);
66+
req.end();
67+
});
68+
expect(body).toBe('ok'); // connected via the vetted 127.0.0.1 resolution
69+
} finally {
70+
server.close();
71+
}
72+
});
73+
74+
it('does not screen when dnsScreen is disabled (our resolver is never wired in)', async () => {
75+
const http = await nodeHttp();
76+
const server = http.createServer((_req: any, res: any) => res.end('ok'));
77+
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
78+
const { port } = server.address();
79+
let called = false;
80+
try {
81+
restore = await installEgressGuard({
82+
shouldBlock,
83+
dnsScreen: false,
84+
lookup: () => {
85+
called = true; // our screening resolver — must stay untouched when disabled
86+
},
87+
});
88+
const body: string = await new Promise((resolve, reject) => {
89+
// family: 4 pins localhost → 127.0.0.1 (avoids ::1 when the server bound only IPv4).
90+
const req = http.request({ hostname: 'localhost', port, path: '/', family: 4 }, (res: any) => {
91+
let data = '';
92+
res.on('data', (c: Buffer) => (data += c));
93+
res.on('end', () => resolve(data));
94+
});
95+
req.on('error', reject);
96+
req.end();
97+
});
98+
expect(body).toBe('ok');
99+
expect(called).toBe(false); // request used the platform resolver, not ours
100+
} finally {
101+
server.close();
102+
}
103+
});
104+
});

0 commit comments

Comments
 (0)