@@ -9,10 +9,20 @@ export class HostedOutboundRequestBlocked extends Schema.TaggedErrorClass<Hosted
99 } ,
1010) { }
1111
12+ export interface HostedResolvedAddress {
13+ readonly address : string ;
14+ readonly family ?: 4 | 6 ;
15+ }
16+
17+ export type HostedHostnameResolver = (
18+ hostname : string ,
19+ ) => Promise < ReadonlyArray < HostedResolvedAddress > > ;
20+
1221export interface HostedHttpClientOptions {
1322 readonly allowLocalNetwork ?: boolean ;
1423 readonly maxRedirects ?: number ;
1524 readonly fetch ?: typeof globalThis . fetch ;
25+ readonly resolveHostname ?: HostedHostnameResolver ;
1626}
1727
1828const parseIpv4 = ( hostname : string ) : readonly [ number , number , number , number ] | null => {
@@ -58,13 +68,37 @@ const parseIpv4MappedIpv6 = (
5868 return [ high >> 8 , high & 0xff , low >> 8 , low & 0xff ] ;
5969} ;
6070
61- const isPrivateIpv4 = ( [ a , b ] : readonly [ number , number , number , number ] ) : boolean =>
71+ const isBlockedIpv4 = ( [ a , b ] : readonly [ number , number , number , number ] ) : boolean =>
6272 a === 0 ||
6373 a === 10 ||
6474 a === 127 ||
75+ ( a === 100 && b >= 64 && b <= 127 ) ||
6576 ( a === 169 && b === 254 ) ||
6677 ( a === 172 && b >= 16 && b <= 31 ) ||
67- ( a === 192 && b === 168 ) ;
78+ ( a === 192 && b === 0 ) ||
79+ ( a === 192 && b === 168 ) ||
80+ ( a === 198 && ( b === 18 || b === 19 ) ) ||
81+ a >= 224 ;
82+
83+ const isBlockedIpv6 = ( hostname : string ) : boolean => {
84+ const normalized = hostname . toLowerCase ( ) ;
85+ if (
86+ normalized === "::" ||
87+ normalized === "::1" ||
88+ normalized === "0:0:0:0:0:0:0:0" ||
89+ normalized === "0:0:0:0:0:0:0:1"
90+ ) {
91+ return true ;
92+ }
93+ const firstWordText = normalized . split ( ":" ) . find ( ( part ) => part . length > 0 ) ;
94+ if ( ! firstWordText || ! / ^ [ 0 - 9 a - f ] { 1 , 4 } $ / . test ( firstWordText ) ) return false ;
95+ const firstWord = Number . parseInt ( firstWordText , 16 ) ;
96+ return (
97+ ( firstWord & 0xffc0 ) === 0xfe80 ||
98+ ( firstWord & 0xfe00 ) === 0xfc00 ||
99+ ( firstWord & 0xff00 ) === 0xff00
100+ ) ;
101+ } ;
68102
69103const isBlockedMetadataHostname = ( hostname : string ) : boolean => {
70104 const normalized = hostname . toLowerCase ( ) ;
@@ -80,15 +114,24 @@ const isLocalOrPrivateHostname = (hostname: string): boolean => {
80114 const normalized = hostname . toLowerCase ( ) . replace ( / ^ \[ | \] $ / g, "" ) ;
81115 if ( normalized === "localhost" || normalized . endsWith ( ".localhost" ) ) return true ;
82116 const ipv4 = parseIpv4 ( normalized ) ;
83- if ( ipv4 ) return isPrivateIpv4 ( ipv4 ) ;
117+ if ( ipv4 ) return isBlockedIpv4 ( ipv4 ) ;
84118 const mappedIpv4 = parseIpv4MappedIpv6 ( normalized ) ;
85- if ( mappedIpv4 ) return isPrivateIpv4 ( mappedIpv4 ) ;
86- return (
87- normalized === "::1" ||
88- normalized . startsWith ( "fe80:" ) ||
89- normalized . startsWith ( "fc" ) ||
90- normalized . startsWith ( "fd" )
91- ) ;
119+ if ( mappedIpv4 ) return isBlockedIpv4 ( mappedIpv4 ) ;
120+ return isBlockedIpv6 ( normalized ) ;
121+ } ;
122+
123+ const isAddressLiteral = ( hostname : string ) : boolean => {
124+ const normalized = hostname . toLowerCase ( ) . replace ( / ^ \[ | \] $ / g, "" ) ;
125+ return parseIpv4 ( normalized ) !== null || / ^ [ 0 - 9 a - f : . ] + $ / i. test ( normalized ) ;
126+ } ;
127+
128+ const resolveHostnameWithNodeDns : HostedHostnameResolver = async ( hostname ) => {
129+ const { lookup } = await import ( "node:dns/promises" ) ;
130+ const addresses = await lookup ( hostname , { all : true , verbatim : true } ) ;
131+ return addresses . map ( ( { address, family } ) => ( {
132+ address,
133+ family : family === 6 ? 6 : 4 ,
134+ } ) ) ;
92135} ;
93136
94137export const validateHostedOutboundUrl = (
@@ -125,6 +168,34 @@ export const validateHostedOutboundUrl = (
125168 reason : "Local and private network addresses are not allowed" ,
126169 } ) ;
127170 }
171+
172+ const normalizedHostname = url . hostname . toLowerCase ( ) . replace ( / ^ \[ | \] $ / g, "" ) ;
173+ if ( ! options . allowLocalNetwork && options . resolveHostname && ! isAddressLiteral ( url . hostname ) ) {
174+ const addresses = yield * Effect . tryPromise ( {
175+ try : ( ) => options . resolveHostname ! ( normalizedHostname ) ,
176+ catch : ( ) =>
177+ new HostedOutboundRequestBlocked ( {
178+ url : value ,
179+ reason : "Hostname could not be resolved" ,
180+ } ) ,
181+ } ) ;
182+
183+ if ( addresses . length === 0 ) {
184+ return yield * new HostedOutboundRequestBlocked ( {
185+ url : value ,
186+ reason : "Hostname did not resolve to an address" ,
187+ } ) ;
188+ }
189+
190+ for ( const { address } of addresses ) {
191+ if ( isLocalOrPrivateHostname ( address ) ) {
192+ return yield * new HostedOutboundRequestBlocked ( {
193+ url : value ,
194+ reason : "Resolved address is local or private" ,
195+ } ) ;
196+ }
197+ }
198+ }
128199 } ) ;
129200
130201const CREDENTIAL_HEADERS = [ "authorization" , "proxy-authorization" , "cookie" ] as const ;
@@ -140,12 +211,16 @@ const guardFetch = (
140211 options : HostedHttpClientOptions ,
141212) : typeof globalThis . fetch =>
142213 ( async ( input , init ) => {
214+ const guardOptions = {
215+ ...options ,
216+ resolveHostname : options . resolveHostname ?? resolveHostnameWithNodeDns ,
217+ } ;
143218 const maxRedirects = options . maxRedirects ?? 10 ;
144219 let current : Parameters < typeof globalThis . fetch > [ 0 ] | URL = input ;
145220 let currentInit = init ;
146221 for ( let redirects = 0 ; redirects <= maxRedirects ; redirects ++ ) {
147222 const url = current instanceof Request ? current . url : String ( current ) ;
148- Effect . runSync ( validateHostedOutboundUrl ( url , options ) ) ;
223+ await Effect . runPromise ( validateHostedOutboundUrl ( url , guardOptions ) ) ;
149224 const response = await underlying ( current , {
150225 ...currentInit ,
151226 redirect : "manual" ,
0 commit comments