Skip to content

Expose the DNS-free half of SSRF validation, and a transport injection point on the client path #1004

Description

@KonstantinMirin

Summary

Two pieces of SSRF machinery already exist in the SDK but are not reachable by an application that
wants to use them, so integrators end up either re-implementing the logic or leaving a dial ungated.
Both asks below are "expose what is already there" — no new policy, no behaviour change for existing
callers.

We hit both in a seller implementation where the rule is the application implements no SSRF policy
of its own
; every outbound request goes through one seam that delegates to adcp.signing.


Ask 1 — resolve_and_validate_host couples four checks, three of which need no DNS

adcp/signing/jwks.py::resolve_and_validate_host (6.6.0) does four things in one call, and the
first three run entirely before socket.getaddrinfo:

  1. scheme check (http/https only) — no DNS
  2. host present + IDNA canonicalization — no DNS
  3. port allowlist — no DNS
  4. getaddrinfo, then per-IP checks (BLOCKED_METADATA_IPS, reserved ranges) — needs DNS

validate_jwks_uri is a thin no-return wrapper over the whole composition, so it exposes nothing
additional.

Use case: registration-time validation of a URL that will be dialled later

A buyer registers a webhook / callback URL. We must judge it at registration, when the buyer can
still fix it — but we must NOT resolve DNS at that moment, because a hostname whose DNS has not yet
propagated is legitimate and must be accepted; it is re-checked with DNS when the callback is
actually dialled.

Today the only public entry points resolve unconditionally, so registration either:

  • rejects a valid not-yet-propagated hostname (wrong, and a support burden), or
  • re-implements steps 1–3 locally — which is what we did, and it is exactly the duplicated
    address-policy the SDK exists to prevent. Our copy has already drifted from yours once.

Note step 4 is also usable without real DNS when the host is an IP literal: getaddrinfo returns
the literal, so literal-address rejection is available with no lookup — it is just entangled with
resolution.

Suggested design

Split, don't flag. Keep resolve_and_validate_host as the composition so no existing caller changes:

def validate_uri_static(uri, *, allowed_ports=None) -> str:
    """Scheme + host/IDNA + port. No DNS. Returns the canonicalized host."""

def validate_resolved_ip(ip, *, allow_private=False) -> None:
    """The per-IP policy: metadata blocklist + reserved ranges."""

def resolve_and_validate_host(uri, *, allow_private=False, allowed_ports=None):
    host = validate_uri_static(uri, allowed_ports=allowed_ports)
    ...  # getaddrinfo, then validate_resolved_ip per address

A resolve_dns: bool = True parameter would also work for us, but the split is nicer: it lets a
caller validate an IP it obtained some other way, and it keeps one definition of each policy.


Ask 2 — the client path has no public transport/client-factory injection

adcp/protocols/mcp.py::_streamable_http_client_factory picks the httpx client factory, but the only
way to influence it is signing_request_hook, which is set internally (client.py:571) and is about
request signing, not transport policy. AgentConfig and both client constructors expose nothing.

Use case: force every SDK dial through the same egress policy

We route all outbound HTTP through one seam. Any library that dials on our behalf silently bypasses
it. With ADCPMultiAgentClient we measured this directly: with our egress policy configured to
refuse loopback, a list_creative_formats against http://127.0.0.1:<port> produced three real
requests
to that origin (/, /mcp, /mcp/) before an unrelated fallback path refused. The
packets left the process.

We cannot fix that from the outside today. Validate-then-connect is not a fix — it is a TOCTOU
window and it duplicates policy.

The frustrating part is that the SDK already ships the right primitive:
adcp/signing/ip_pinned_transport.py::IpPinnedTransport, whose own docstring says "pass this to
httpx.Client(transport=...) and everything else works unchanged". It just is not wired into the
client path.

Suggested design

Accept an httpx_client_factory (or a transport) on AgentConfig / the client constructors, and use
it in _streamable_http_client_factory when provided. fastmcp's StreamableHttpTransport already
takes exactly this parameter, so there is precedent in the immediate neighbourhood:

StreamableHttpTransport(url=..., httpx_client_factory=my_factory)

With that knob an integrator can hand you a client built on IpPinnedTransport (or their own
policy) and the SDK physically cannot dial around it.


Why these are one issue

Both are the same shape: the SDK has the correct logic, and an application that wants to use it
rather than reimplement it has no way in. Ask 1 makes the validation reusable; ask 2 makes the
enforcement unavoidable. Happy to send a PR for either if the shape above looks right.

Environment: adcp==6.6.0, Python 3.12.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions