diff --git a/src/protect/egress.js b/src/protect/egress.js index 3cb0720..a4915eb 100644 --- a/src/protect/egress.js +++ b/src/protect/egress.js @@ -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 () => {}; @@ -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 */ @@ -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; @@ -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); }; @@ -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)]; +} diff --git a/src/protect/protect.d.ts b/src/protect/protect.d.ts index 35e5530..62d0900 100644 --- a/src/protect/protect.d.ts +++ b/src/protect/protect.d.ts @@ -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; diff --git a/src/protect/runtime.js b/src/protect/runtime.js index 25b665a..dd629d4 100644 --- a/src/protect/runtime.js +++ b/src/protect/runtime.js @@ -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; diff --git a/tests/protect/egress-dns.test.ts b/tests/protect/egress-dns.test.ts new file mode 100644 index 0000000..a2a64b5 --- /dev/null +++ b/tests/protect/egress-dns.test.ts @@ -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 => { + 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((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((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(); + } + }); +});