@@ -269,6 +269,43 @@ async def _dns_validate_host(host: str, port: int) -> None:
269269 _check_safe_host (addr , "resolved address" )
270270
271271
272+ def _owned_pinned_client (url : str , timeout : float ) -> httpx .AsyncClient :
273+ """Build an SDK-owned ``AsyncClient`` pinned to ``url``'s validated IP.
274+
275+ Resolves the host once via :func:`resolve_and_validate_host` and wires
276+ the resulting IP into an :class:`AsyncIpPinnedTransport`, so httpx
277+ connects to the address the SSRF gate approved instead of re-resolving
278+ at connect time. This is what closes the DNS-rebinding TOCTOU that the
279+ :func:`_dns_validate_host` pre-check alone leaves open: the pre-check
280+ and the connect now observe the *same* resolution.
281+
282+ ``trust_env=False`` so an ``HTTPS_PROXY`` / ``HTTP_PROXY`` in the
283+ environment can't route the request through a proxy pool that ignores
284+ the pinned backend — that would reopen the same TOCTOU.
285+
286+ Only call this from branches where the SDK owns transport construction.
287+ When a caller injects their own client the SDK does not control the
288+ transport, so the pre-check remains the only available guard there.
289+
290+ Raises:
291+ AdagentsValidationError: If the host doesn't resolve or every
292+ resolved address is in a blocked/reserved range. Maps the
293+ transport layer's :class:`SSRFValidationError` onto the
294+ adagents error type so callers see one exception family.
295+ """
296+ # Lazy import: keeps httpcore (a transport-only dependency) off the
297+ # adagents module-load path and avoids a load-time cycle, matching
298+ # adcp.signing.jwks.default_jwks_fetcher.
299+ from adcp .signing .ip_pinned_transport import build_async_ip_pinned_transport
300+ from adcp .signing .jwks import SSRFValidationError
301+
302+ try :
303+ transport = build_async_ip_pinned_transport (url )
304+ except SSRFValidationError as e :
305+ raise AdagentsValidationError (f"SSRF validation failed for { url !r} : { e } " ) from e
306+ return httpx .AsyncClient (transport = transport , timeout = timeout , trust_env = False )
307+
308+
272309def _validate_publisher_domain (domain : str ) -> str :
273310 """Validate and sanitize publisher domain for security.
274311
@@ -716,7 +753,7 @@ async def _fetch_ads_txt_managerdomains(
716753 url , headers = headers , timeout = timeout , follow_redirects = False
717754 )
718755 else :
719- async with httpx . AsyncClient ( ) as new_client :
756+ async with _owned_pinned_client ( url , timeout ) as new_client :
720757 response = await new_client .get (
721758 url , headers = headers , timeout = timeout , follow_redirects = False
722759 )
@@ -727,6 +764,12 @@ async def _fetch_ads_txt_managerdomains(
727764 return _parse_managerdomains (response .text )
728765 except (httpx .TimeoutException , httpx .RequestError ):
729766 return []
767+ except AdagentsValidationError :
768+ # The pinned-transport build re-resolves the host; if it now points
769+ # at a blocked address (DNS rebinding between the pre-check and the
770+ # connect), fail closed. This fallback is best-effort, so a blocked
771+ # resolution is "no MANAGERDOMAIN found", same as a network error.
772+ return []
730773
731774
732775def _ensure_safe_manager_domain (manager_domain : str ) -> str | None :
@@ -1056,13 +1099,20 @@ async def _fetch_adagents_url(
10561099 parsed .hostname or "" , parsed .port or (443 if parsed .scheme == "https" else 80 )
10571100 )
10581101
1102+ # When the SDK owns the client, pin it to the validated IP so httpx
1103+ # connects to the address the SSRF gate approved rather than re-resolving
1104+ # at connect time. A failed resolve/SSRF check surfaces from
1105+ # _owned_pinned_client as AdagentsValidationError — not an httpx error, so
1106+ # it propagates past the handlers below, which is the correct fail-closed
1107+ # outcome for the primary fetch path (unlike the best-effort ads.txt
1108+ # fallback, we do NOT swallow it).
10591109 try :
10601110 if client is not None :
10611111 body , status_code , response_headers = await _stream_capped (
10621112 client , url , headers , timeout , max_bytes
10631113 )
10641114 else :
1065- async with httpx . AsyncClient ( ) as new_client :
1115+ async with _owned_pinned_client ( url , timeout ) as new_client :
10661116 body , status_code , response_headers = await _stream_capped (
10671117 new_client , url , headers , timeout , max_bytes
10681118 )
@@ -2207,13 +2257,17 @@ async def fetch_agent_authorizations_from_directory(
22072257
22082258 headers = {"User-Agent" : "AdCP-Client/1.0" , "Accept" : "application/json" }
22092259
2260+ # SDK-owned client is pinned to the validated IP (see _fetch_adagents_url).
2261+ # A failed resolve/SSRF check raises AdagentsValidationError, which
2262+ # propagates past the httpx handlers below — the correct fail-closed
2263+ # outcome (we do not convert it into an empty result).
22102264 try :
22112265 if client is not None :
22122266 body , status_code , _ = await _stream_capped (
22132267 client , request_url , headers , timeout , MAX_DIRECTORY_PAGE_BYTES
22142268 )
22152269 else :
2216- async with httpx . AsyncClient ( ) as new_client :
2270+ async with _owned_pinned_client ( request_url , timeout ) as new_client :
22172271 body , status_code , _ = await _stream_capped (
22182272 new_client , request_url , headers , timeout , MAX_DIRECTORY_PAGE_BYTES
22192273 )
0 commit comments