Skip to content

security(adagents): pin DNS to prevent rebinding on SSRF gate #757

@bokelley

Description

@bokelley

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

  • Custom httpx.AsyncHTTPTransport (or equivalent) that resolves once, gates every returned address, and connects to the pre-resolved IP.
  • Used by both the streaming adagents.json path and the ads.txt MANAGERDOMAIN path.
  • Test: simulate a rebinding DNS that returns a public IP first then a private IP — the SDK rejects on the second lookup (or, with pinned resolution, never re-resolves).
  • Test: per-hop resolution remains correct for legitimately public targets across pointer + authoritative hops.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    claude-triagedno-triageSkip the Claude triage bot — human or designated agent will handle this issue

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions