Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 100 additions & 5 deletions src/protect/egress.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@

/**
* @param {{ shouldBlock: (url:string, host:string|null, method:string)=>boolean,
* onBlock?: (info:{url:string,host:string|null,method:string})=>void }} opts
* onBlock?: (info:{url:string,host:string|null,method:string})=>void,
* dnsScreen?: boolean,
* lookup?: Function }} opts
* @returns {Promise<() => void>} uninstall (restores every patched surface)
*/
export async function installEgressGuard({ shouldBlock, onBlock } = {}) {
export async function installEgressGuard({ shouldBlock, onBlock, dnsScreen = true, lookup } = {}) {
const restores = [];
if (typeof shouldBlock !== 'function') return () => {};

Expand Down Expand Up @@ -44,11 +46,29 @@ export async function installEgressGuard({ shouldBlock, onBlock } = {}) {
});
}

// 2. node:http / node:https — best-effort; absent on Workers/Deno-without-node (import throws).
// DNS-rebinding screen for the Node http path: instead of trusting the hostname, resolve it
// ourselves, block if it maps to a disallowed address, and PIN the connection to that vetted
// resolution — so a name that passes the hostname check but resolves (or re-resolves) to an
// internal/metadata IP can't slip through (time-of-check vs time-of-use). Needs node:dns +
// node:net; absent on edge runtimes, where the hostname rules still apply.
let screen = null;
if (dnsScreen) {
try {
const resolveLookup = lookup ?? (await import('node:dns')).lookup;
const { isIP } = await import('node:net');
if (typeof resolveLookup === 'function' && typeof isIP === 'function') {
screen = { lookup: resolveLookup, isIP };
}
} catch {
screen = null; // no node:dns/net here — skip, hostname rules still apply
}
}

// node:http / node:https — best-effort; absent on Workers/Deno-without-node (import throws).
for (const moduleName of ['node:http', 'node:https']) {
try {
const mod = await import(moduleName);
const restore = patchHttpModule(mod.default ?? mod, block);
const restore = patchHttpModule(mod.default ?? mod, block, screen);
if (restore) restores.push(restore);
} catch {
/* module not available on this runtime — skip */
Expand Down Expand Up @@ -93,7 +113,7 @@ export async function installEgressGuard({ shouldBlock, onBlock } = {}) {
}

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

Expand Down Expand Up @@ -150,3 +178,70 @@ function normalizeHost(raw) {
if ((host.match(/:/g) || []).length > 1) return host; // bare IPv6 — no host:port to split
return host.split(':')[0];
}

// Given the addresses a hostname resolved to, return the first one the policy blocks (else null).
// Reuses the same `block` predicate as the hostname check, so egress rules + allowlist apply to
// the resolved IP too. Exported for tests.
export function screenResolved(addresses, target, block) {
for (const a of addresses || []) {
const ip = a && typeof a === 'object' ? a.address : a;
if (ip && block(target.url, ip, target.method)) return ip;
}
return null;
}

// Build a DNS `lookup` that screens every resolved address before the socket connects, then hands
// back the vetted addresses (pinning the connection to what we checked). A blocked address errors
// the connection; a resolver error or our own failure falls through to normal resolution (fail-open).
function withScreeningLookup(args, target, block, lookup) {
const screeningLookup = (hostname, options, callback) => {
let opts = options;
let cb = callback;
if (typeof opts === 'function') {
cb = opts;
opts = {};
}
if (!opts || typeof opts !== 'object') opts = {};
try {
lookup(hostname, { ...opts, all: true }, (err, addresses) => {
if (err) return cb(err);
const list = Array.isArray(addresses) ? addresses : [];
const blocked = screenResolved(list, target, block);
if (blocked) {
return cb(new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host} resolved to ${blocked}`));
}
if (opts.all) return cb(null, list);
const first = list[0];
if (!first) return cb(new Error(`Patchstack: could not resolve ${hostname}`));
return cb(null, first.address, first.family);
});
} catch {
// Our screening threw — fall back to a plain resolution so we never break a request ourselves.
try {
lookup(hostname, opts, cb);
} catch {
cb(new Error(`Patchstack: lookup failed for ${hostname}`));
}
}
};
return injectLookupOption(args, screeningLookup);
}

// Return a new args array for http(s).request with our `lookup` set on the options object (cloned,
// never mutating the caller's object), inserting an options object when the call didn't pass one.
function injectLookupOption(args, lookup) {
const first = args[0];
if (first && typeof first === 'object' && !(first instanceof URL)) {
return [{ ...first, lookup }, ...args.slice(1)];
}
const rest = args.slice(1);
const optIdx = rest.findIndex((a) => a && typeof a === 'object' && !(a instanceof URL));
if (optIdx !== -1) {
const next = [...rest];
next[optIdx] = { ...rest[optIdx], lookup };
return [first, ...next];
}
const cbIdx = rest.findIndex((a) => typeof a === 'function');
if (cbIdx === -1) return [first, { lookup }, ...rest];
return [first, ...rest.slice(0, cbIdx), { lookup }, ...rest.slice(cbIdx)];
}
6 changes: 6 additions & 0 deletions src/protect/protect.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ export interface CreateProtectionOptions {
egress?: boolean;
/** Hosts exempt from egress screening. */
allowHosts?: string[];
/**
* Screen the Node http/https path against DNS rebinding: resolve outbound hostnames and block +
* pin to the vetted address when they map to a disallowed (internal/metadata) IP. Default true;
* only active when `egress` is on and node:dns is available (a no-op on edge runtimes).
*/
screenDns?: boolean;
/** Redaction mask (string or per-category function). Default "[REDACTED]". */
maskWith?: string | ((category?: string) => string);
onError?: (err: unknown) => void;
Expand Down
6 changes: 5 additions & 1 deletion src/protect/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,11 @@ export async function createProtection(options = {}) {

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

return protection;
Expand Down
104 changes: 104 additions & 0 deletions tests/protect/egress-dns.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { afterEach, describe, expect, it } from 'vitest';
import { installEgressGuard, screenResolved } from '../../src/protect/egress.js';

// A hostname that passes the name check but resolves to an internal/metadata IP must be blocked
// (DNS rebinding). We inject a fake resolver so the tests are deterministic — no real DNS.

const isInternal = (host: string) => /^(?:127\.|10\.|169\.254\.|192\.168\.)/.test(String(host)) || host === '::1';
const shouldBlock = (_url: string, host: string | null) => (host ? isInternal(host) : false);
const target = { url: 'http://rebind.test/', host: 'rebind.test', method: 'GET' };
const nodeHttp = async (): Promise<any> => {
const ns: any = await import('node:http');
return ns.default ?? ns;
};

let restore: (() => void) | undefined;
afterEach(() => {
restore?.();
restore = undefined;
});

describe('screenResolved', () => {
it('flags the first internal address a hostname resolves to', () => {
const hit = screenResolved([{ address: '93.184.216.34' }, { address: '169.254.169.254' }], target, shouldBlock);
expect(hit).toBe('169.254.169.254');
});

it('returns null when every resolved address is public', () => {
expect(screenResolved([{ address: '93.184.216.34' }, { address: '1.1.1.1' }], target, shouldBlock)).toBeNull();
});
});

describe('egress DNS-rebinding screen (node:http)', () => {
it('blocks a hostname that resolves to an internal address', async () => {
restore = await installEgressGuard({
shouldBlock,
lookup: (_h: string, _o: any, cb: any) => cb(null, [{ address: '169.254.169.254', family: 4 }]),
});
const http = await nodeHttp();
const err: any = await new Promise((resolve) => {
const req = http.request('http://rebind.test/');
req.on('error', resolve);
req.end();
});
expect(String(err)).toMatch(/Patchstack blocked/);
expect(String(err)).toMatch(/169\.254\.169\.254/); // reports what it resolved to
});

it('allows and pins a hostname that resolves to a permitted address', async () => {
const http = await nodeHttp();
const server = http.createServer((_req: any, res: any) => res.end('ok'));
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
const { port } = server.address();
try {
// 127.* is permitted by this predicate, so the pinned resolution reaches the local server.
restore = await installEgressGuard({
shouldBlock: (_url: string, host: string | null) => /^(?:169\.254\.|10\.|192\.168\.)/.test(String(host)),
lookup: (_h: string, _o: any, cb: any) => cb(null, [{ address: '127.0.0.1', family: 4 }]),
});
const body: string = await new Promise((resolve, reject) => {
const req = http.request({ hostname: 'public.test', port, path: '/' }, (res: any) => {
let data = '';
res.on('data', (c: Buffer) => (data += c));
res.on('end', () => resolve(data));
});
req.on('error', reject);
req.end();
});
expect(body).toBe('ok'); // connected via the vetted 127.0.0.1 resolution
} finally {
server.close();
}
});

it('does not screen when dnsScreen is disabled (our resolver is never wired in)', async () => {
const http = await nodeHttp();
const server = http.createServer((_req: any, res: any) => res.end('ok'));
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
const { port } = server.address();
let called = false;
try {
restore = await installEgressGuard({
shouldBlock,
dnsScreen: false,
lookup: () => {
called = true; // our screening resolver — must stay untouched when disabled
},
});
const body: string = await new Promise((resolve, reject) => {
// family: 4 pins localhost → 127.0.0.1 (avoids ::1 when the server bound only IPv4).
const req = http.request({ hostname: 'localhost', port, path: '/', family: 4 }, (res: any) => {
let data = '';
res.on('data', (c: Buffer) => (data += c));
res.on('end', () => resolve(data));
});
req.on('error', reject);
req.end();
});
expect(body).toBe('ok');
expect(called).toBe(false); // request used the platform resolver, not ours
} finally {
server.close();
}
});
});
Loading