@@ -34,26 +34,38 @@ export interface HostedHttpClientOptions {
3434 readonly dnsResolutionTimeoutMillis ?: number ;
3535}
3636
37+ // Octets are decimal-only and leading zeros are rejected. inet_aton reads a
38+ // leading zero as octal, so accepting "0177.0.0.1" here would classify it as
39+ // 177.0.0.1 (public) while the platform resolver dials 127.0.0.1 — the guard
40+ // and the connection would disagree about the destination. WHATWG normalizes
41+ // these forms, so the URL path never produces one, but a resolver answer
42+ // reaches isAllowedResolvedAddress unnormalized.
3743const parseIpv4 = ( hostname : string ) : readonly [ number , number , number , number ] | null => {
3844 const parts = hostname . split ( "." ) ;
3945 if ( parts . length !== 4 ) return null ;
4046 const parsed : number [ ] = [ ] ;
4147 for ( const part of parts ) {
42- if ( ! / ^ \d + $ / . test ( part ) ) return null ;
48+ if ( ! / ^ (?: 0 | [ 1 - 9 ] \d * ) $ / . test ( part ) ) return null ;
4349 const value = Number ( part ) ;
4450 if ( ! Number . isInteger ( value ) || value < 0 || value > 255 ) return null ;
4551 parsed . push ( value ) ;
4652 }
4753 return parsed as [ number , number , number , number ] ;
4854} ;
4955
50- const parseIpv6Groups = ( text : string ) : number [ ] | null => {
56+ // `allowTrailingQuad` is the caller's statement that this segment ends the
57+ // whole address, not merely that it ends its own half. Deciding it from the
58+ // segment alone would legalize a quad at the end of the head half of a
59+ // compressed literal: "127.0.0.1::1" would parse, land 0x7f00 in the leading
60+ // word where no prefix or mask matches it, and be classified public.
61+ const parseIpv6Groups = ( text : string , allowTrailingQuad : boolean ) : number [ ] | null => {
5162 if ( text === "" ) return [ ] ;
5263 const parts = text . split ( ":" ) ;
5364 const groups : number [ ] = [ ] ;
5465 for ( const [ index , part ] of parts . entries ( ) ) {
5566 // A trailing dotted quad is the only place IPv4 syntax is legal.
5667 if ( index === parts . length - 1 && part . includes ( "." ) ) {
68+ if ( ! allowTrailingQuad ) return null ;
5769 const dotted = parseIpv4 ( part ) ;
5870 if ( ! dotted ) return null ;
5971 groups . push ( ( dotted [ 0 ] << 8 ) | dotted [ 1 ] , ( dotted [ 2 ] << 8 ) | dotted [ 3 ] ) ;
@@ -78,11 +90,12 @@ const parseIpv6 = (hostname: string): ReadonlyArray<number> | null => {
7890 const [ head , tail , ...extra ] = hostname . replace ( / % .* $ / , "" ) . split ( "::" ) ;
7991 if ( extra . length > 0 ) return null ;
8092
81- const high = parseIpv6Groups ( head ) ;
93+ // The head half ends the address only when there is no "::" after it.
94+ const high = parseIpv6Groups ( head , tail === undefined ) ;
8295 if ( high === null ) return null ;
8396 if ( tail === undefined ) return high . length === 8 ? high : null ;
8497
85- const low = parseIpv6Groups ( tail ) ;
98+ const low = parseIpv6Groups ( tail , true ) ;
8699 if ( low === null ) return null ;
87100 const elided = 8 - high . length - low . length ;
88101 if ( elided < 1 ) return null ;
@@ -193,10 +206,15 @@ const isAllowedResolvedAddress = (address: string): boolean => {
193206const isAddressLiteral = ( normalized : string ) : boolean =>
194207 parseIpv4 ( normalized ) !== null || normalized . includes ( ":" ) ;
195208
209+ // `empty` separates "the resolver answered, with nothing" from "the resolver
210+ // did not answer". Both fail closed and both must stay out of the success
211+ // cache, but they are different operator diagnoses, so the block reason keeps
212+ // them apart rather than collapsing into one message.
196213class HostedHostnameResolutionFailed extends Schema . TaggedErrorClass < HostedHostnameResolutionFailed > ( ) (
197214 "HostedHostnameResolutionFailed" ,
198215 {
199216 hostname : Schema . String ,
217+ empty : Schema . Boolean ,
200218 } ,
201219) { }
202220
@@ -210,13 +228,24 @@ type GuardResolver = (
210228 hostname : string ,
211229) => Effect . Effect < ReadonlyArray < HostedResolvedAddress > , HostedHostnameResolutionFailed > ;
212230
231+ // An empty answer becomes a failure at the resolver boundary, not at the call
232+ // site, so it reaches every caller the same way: the cached path needs it in
233+ // the error channel for the zero failure TTL to drop it, and putting it here
234+ // rather than inside the cache keeps the uncached path — the exported
235+ // validateHostedOutboundUrl — from having a second copy of the rule.
213236const toGuardResolver =
214237 ( resolve : HostedHostnameResolver ) : GuardResolver =>
215238 ( hostname ) =>
216239 Effect . tryPromise ( {
217240 try : ( signal ) => resolve ( hostname , signal ) ,
218- catch : ( ) => new HostedHostnameResolutionFailed ( { hostname } ) ,
219- } ) ;
241+ catch : ( ) => new HostedHostnameResolutionFailed ( { hostname, empty : false } ) ,
242+ } ) . pipe (
243+ Effect . flatMap ( ( addresses ) =>
244+ addresses . length === 0
245+ ? Effect . fail ( new HostedHostnameResolutionFailed ( { hostname, empty : true } ) )
246+ : Effect . succeed ( addresses ) ,
247+ ) ,
248+ ) ;
220249
221250const DNS_CACHE_TTL_MILLIS = 60_000 ;
222251const DNS_CACHE_CAPACITY = 256 ;
@@ -261,12 +290,18 @@ const withResolutionCache = (
261290 // the request instead is a worse outcome than the wait. Callers that will
262291 // not wait that long abort their own fetch, which the detached lookup
263292 // survives on behalf of everyone else waiting on it.
293+ //
294+ // An empty answer is already a failure by the time it reaches the cache —
295+ // toGuardResolver raises it — so the zero failure TTL drops it. Were it a
296+ // success, one transient empty answer would stamp the full TTL on "no
297+ // addresses" and blackhole the hostname for the rest of the window; the
298+ // uncached code re-resolved every request and never could.
264299 const bounded : GuardResolver = ( hostname ) =>
265300 Effect . timeoutOrElse ( resolve ( hostname ) , {
266301 duration : Duration . millis (
267302 options . dnsResolutionTimeoutMillis ?? DNS_RESOLUTION_TIMEOUT_MILLIS ,
268303 ) ,
269- orElse : ( ) => Effect . fail ( new HostedHostnameResolutionFailed ( { hostname } ) ) ,
304+ orElse : ( ) => Effect . fail ( new HostedHostnameResolutionFailed ( { hostname, empty : false } ) ) ,
270305 } ) ;
271306 const cache = Effect . runSync (
272307 Cache . makeWith ( bounded , {
@@ -321,24 +356,22 @@ const validateOutboundUrl = (
321356 }
322357
323358 if ( ! options . allowLocalNetwork && ! isAddressLiteral ( normalizedHostname ) ) {
359+ // The empty answer is already a failure by the time it arrives here —
360+ // the cached lookup raises it, so it takes the zero failure TTL instead
361+ // of being retained as a successful "no addresses" for the full window.
324362 const addresses = yield * resolve ( normalizedHostname ) . pipe (
325- Effect . catchTag ( "HostedHostnameResolutionFailed" , ( ) =>
363+ Effect . catchTag ( "HostedHostnameResolutionFailed" , ( error ) =>
326364 Effect . fail (
327365 new HostedOutboundRequestBlocked ( {
328366 url : value ,
329- reason : "Hostname could not be resolved" ,
367+ reason : error . empty
368+ ? "Hostname did not resolve to an address"
369+ : "Hostname could not be resolved" ,
330370 } ) ,
331371 ) ,
332372 ) ,
333373 ) ;
334374
335- if ( addresses . length === 0 ) {
336- return yield * new HostedOutboundRequestBlocked ( {
337- url : value ,
338- reason : "Hostname did not resolve to an address" ,
339- } ) ;
340- }
341-
342375 for ( const { address } of addresses ) {
343376 if ( ! isAllowedResolvedAddress ( address ) ) {
344377 return yield * new HostedOutboundRequestBlocked ( {
0 commit comments