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+ }
0 commit comments