Context
The SSRF gate added in #753 (_check_safe_host at src/adcp/adagents.py:178) validates publisher_domain and authoritative_location hostnames against IP literals and well-known internal hostnames at the string level. It does not pin DNS resolution.
A hostile DNS server can return a public IP on the first lookup (passes the gate) and a private IP — 127.0.0.1, 10.0.0.5, 169.254.169.254 — on the second lookup at connect time. This is DNS rebinding, the standard escape for string-level SSRF gates.
This was explicitly called out as deferred in the PR body of #753:
DNS pinning to harden against rebinding (security H1) — needs a custom httpx transport.
And in the docstring of _check_safe_host itself:
This is a string-level gate — it catches IP literals and well-known private hostnames, but does not pin DNS resolution. A hostile DNS server that returns a public IP on first lookup and a private IP on connect (DNS rebinding) is out of scope; see security follow-up.
What needs to change
The SDK should resolve each outbound hostname once, validate every returned address against the IP class gate (_check_safe_host already covers IPv4/IPv6 literals — extract its IP-block logic into a shared helper), and pin the connection to the validated IP.
The standard pattern in httpx:
import socket
import httpx
async def _safe_get(url: str, timeout: float, ...) -> httpx.Response:
parsed = urlparse(url)
hostname = parsed.hostname or ""
# Single resolve via getaddrinfo
infos = socket.getaddrinfo(hostname, parsed.port or 443, type=socket.SOCK_STREAM)
addresses = {info[4][0] for info in infos}
for addr in addresses:
_check_safe_host(addr, "resolved address") # raises on private/reserved
# Pin via httpx transport that overrides the resolver
transport = httpx.HTTPTransport(local_address=...) # or a custom resolver
...
A cleaner production approach is a custom httpx.AsyncHTTPTransport subclass that overrides connection establishment to use the pre-resolved IP, with the Host: header preserved.
Surface affected
Every outbound HTTP call in src/adcp/adagents.py constructed from publisher-controlled input:
The same transport should be threaded through all three.
Out of scope for this issue
Acceptance criteria
Related
Context
The SSRF gate added in #753 (
_check_safe_hostatsrc/adcp/adagents.py:178) validates publisher_domain and authoritative_location hostnames against IP literals and well-known internal hostnames at the string level. It does not pin DNS resolution.A hostile DNS server can return a public IP on the first lookup (passes the gate) and a private IP —
127.0.0.1,10.0.0.5,169.254.169.254— on the second lookup at connect time. This is DNS rebinding, the standard escape for string-level SSRF gates.This was explicitly called out as deferred in the PR body of #753:
And in the docstring of
_check_safe_hostitself:What needs to change
The SDK should resolve each outbound hostname once, validate every returned address against the IP class gate (
_check_safe_hostalready covers IPv4/IPv6 literals — extract its IP-block logic into a shared helper), and pin the connection to the validated IP.The standard pattern in httpx:
A cleaner production approach is a custom
httpx.AsyncHTTPTransportsubclass that overrides connection establishment to use the pre-resolved IP, with theHost:header preserved.Surface affected
Every outbound HTTP call in
src/adcp/adagents.pyconstructed from publisher-controlled input:_resolve_direct→_fetch_adagents_url→_stream_capped(publisher_domain + authoritative_location)_fetch_ads_txt_managerdomains(publisher_domain + manager_domain post-fix(adagents): disable follow_redirects on ads.txt MANAGERDOMAIN fetch #754)_try_managerdomain_fallback(manager_domain resolved from ads.txt)The same transport should be threaded through all three.
Out of scope for this issue
Acceptance criteria
httpx.AsyncHTTPTransport(or equivalent) that resolves once, gates every returned address, and connects to the pre-resolved IP.Related
follow_redirects=Falseto ads.txt)