@@ -78,7 +78,7 @@ def _is_public(ip: str) -> bool:
7878 return addr .is_global and not addr .is_multicast
7979
8080
81- async def _resolve_public_ip (host : str ) -> str :
81+ async def resolve_public_ip (host : str ) -> str :
8282 """Resolve *host* and return one address, rejecting any private result."""
8383 # A literal IP address skips DNS entirely.
8484 try :
@@ -153,7 +153,7 @@ async def fetch_public(
153153 parsed = httpx .URL (url )
154154 if parsed .scheme != "https" :
155155 raise FetchHardError ("non-https URL" )
156- ip = await _resolve_public_ip (parsed .host )
156+ ip = await resolve_public_ip (parsed .host )
157157
158158 # Pin the connection to the validated IP; keep name-based TLS via
159159 # sni_hostname and the Host header.
@@ -282,7 +282,7 @@ async def post_public(
282282 parsed = httpx .URL (url )
283283 if parsed .scheme != "https" :
284284 return PostResult (None , "non-https URL" , None )
285- ip = await _resolve_public_ip (parsed .host )
285+ ip = await resolve_public_ip (parsed .host )
286286 except (FetchHardError , FetchTransientError , httpx .InvalidURL ) as exc :
287287 # Transient DNS failures included: delivery outcomes are DATA for
288288 # the retry ladder, never exceptions that skip attempt recording.
@@ -328,4 +328,82 @@ async def validate_public_https_url(url: str) -> None:
328328 parsed = httpx .URL (url )
329329 if parsed .scheme != "https" :
330330 raise FetchHardError ("URL must be https" )
331- await _resolve_public_ip (parsed .host )
331+ await resolve_public_ip (parsed .host )
332+
333+
334+ @dataclass (frozen = True )
335+ class ChainHop :
336+ url : str
337+ status : int | None # None: this hop never answered
338+
339+
340+ @dataclass (frozen = True )
341+ class ExpandedChain :
342+ hops : list [ChainHop ]
343+ final_url : str
344+ final_status : int | None
345+ truncated : bool
346+
347+
348+ async def expand_public (
349+ url : str ,
350+ * ,
351+ timeout : float = 5.0 ,
352+ max_redirects : int = 10 ,
353+ user_agent : str = DEFAULT_USER_AGENT ,
354+ ) -> ExpandedChain :
355+ """Follow *url*'s redirect chain hop by hop and report every stop.
356+
357+ Unlike fetch_public this never reads bodies and allows plain-http
358+ hops — real shortener chains bounce through http trackers, and only
359+ headers ever ride the wire here. Every hop still gets the full SSRF
360+ guard: public-DNS resolution, IP pinning, no auto-redirects.
361+ """
362+ hops : list [ChainHop ] = []
363+ for _hop in range (max_redirects + 1 ):
364+ parsed = httpx .URL (url )
365+ if parsed .scheme not in ("http" , "https" ):
366+ raise FetchHardError ("unsupported scheme" )
367+ try :
368+ ip = await resolve_public_ip (parsed .host )
369+ except FetchHardError :
370+ if not hops :
371+ raise
372+ # Mid-chain dead end (NXDOMAIN, private space): report the
373+ # chain up to it rather than erasing what we learned.
374+ hops .append (ChainHop (url , None ))
375+ return ExpandedChain (hops , url , None , False )
376+ pinned = parsed .copy_with (host = _bracket (ip ))
377+ async with httpx .AsyncClient (follow_redirects = False , timeout = timeout ) as client :
378+ request = client .build_request (
379+ "GET" ,
380+ pinned ,
381+ headers = {
382+ "Host" : parsed .host ,
383+ "User-Agent" : user_agent ,
384+ "Accept-Encoding" : "identity" ,
385+ },
386+ extensions = (
387+ {"sni_hostname" : parsed .host } if parsed .scheme == "https" else {}
388+ ),
389+ )
390+ try :
391+ resp = await client .send (request , stream = True )
392+ except (httpx .TimeoutException , httpx .TransportError ) as exc :
393+ if not hops :
394+ raise FetchTransientError (str (exc )) from exc
395+ hops .append (ChainHop (url , None ))
396+ return ExpandedChain (hops , url , None , False )
397+ try :
398+ status = resp .status_code
399+ hops .append (ChainHop (str (parsed ), status ))
400+ if status in _REDIRECT_STATUSES :
401+ location = resp .headers .get ("location" )
402+ if not location :
403+ return ExpandedChain (hops , str (parsed ), status , False )
404+ url = str (parsed .join (location ))
405+ continue
406+ return ExpandedChain (hops , str (parsed ), status , False )
407+ finally :
408+ await resp .aclose ()
409+ return ExpandedChain (hops , url , None , True )
0 commit comments