Skip to content

Commit 84b837e

Browse files
bokelleyclaude
andauthored
feat(signing): close 4 SSRF gaps and add opt-in port hardening (foundation audit)
* fix(signing): close 5 SSRF and tenant-isolation gaps from foundation audit Pre-foundation cleanup surfacing from the v6.0 DecisioningPlatform foundation audit. Each fix closes a real bug or spec gap in the existing adcp.signing surface independently of the framework work that builds on top. 1. Port allowlist for SSRF-validated outbound HTTP (adcp.signing.jwks.{validate_jwks_uri, resolve_and_validate_host}, ip_pinned_transport.{build_ip_pinned_transport, build_async_ip_pinned_transport}) - Default permits {443, 8443}; rejects :25, :6379, :11211, etc. on resolved public IPs. Buyers can no longer smuggle traffic to internal SMTP / Redis / Memcached via webhook URLs on non-standard ports even when the IP itself is routable. - Configurable via allowed_ports kwarg; empty frozenset is the test-only escape hatch. - Test: tests/conformance/signing/test_jwks.py (test_ssrf_rejects_disallowed_ports + parametrized matrix) 2. WebhookSender owned-client path uses pin-and-bind transport (adcp.webhook_sender.WebhookSender._send_bytes) - Previous implementation reused a single httpx.AsyncClient across all destinations and bypassed the IP-pinned transport entirely. A buyer-supplied webhook URL pointing at 127.0.0.1 or AWS metadata would deliver successfully. - Now: when the sender owns its httpx client (default), every delivery builds a per-request AsyncIpPinnedTransport. Per-request re-resolution is intentional — keeping a pinned transport alive across deliveries to the same hostname would defeat the rebinding defense. - When the operator supplies their own client (vetted egress proxy, ASGI test transport), the framework trusts them completely; the operator owns SSRF guarantees on their transport. - Tests: test_owned_client_rejects_loopback_destination, test_owned_client_rejects_disallowed_port 3. Tenant-scoped JWKS resolver Protocol (adcp.signing.jwks.{JwksResolver, AsyncJwksResolver}, adcp.signing.verifier.VerifyOptions, webhook_verifier.WebhookVerifyOptions) - Adds optional ``tenant_id`` kwarg to the resolver Protocol so a resolver instance shared across tenants can refuse keys outside the active tenant's published JWKS. Cross-tenant key confusion (a buyer signing for tenant B who knows tenant A's key_id) is closed at the resolver layer, not the verifier. - Single-tenant in-tree impls (Static, Caching, AsyncCaching) accept the kwarg as a pass-through — tenant scoping is a wrapper concern, and adopters compose tenant-scoped resolvers around existing single-tenant resolvers. - VerifyOptions.tenant_id and WebhookVerifyOptions.tenant_id thread the value through; verifier.py:227 passes it on resolver call. - Test: tests/conformance/signing/test_jwks.py (test_tenant_scoping_wrapper_pattern — reference pattern for adopters; test_static_resolver_accepts_tenant_id_kwarg — backward-compat invariant) 4. content-digest required-by-default for inbound request signing (adcp.signing.verifier.VerifierCapability) - Default already correct (covers_content_digest="required" at verifier.py:95). Adds a regression test pinning the default so a future "make it lenient" refactor surfaces in CI. - Body integrity must be authenticated end-to-end; "either" or "forbidden" lets a MITM inside TLS termination swap bodies on signed requests whose digest isn't covered. - Test: tests/conformance/signing/test_verifier_defaults.py (test_default_covers_content_digest_is_required) 5. WebhookPayload.operation_id docstring fix (adcp.webhooks.create_mcp_webhook_payload) - Docstring previously said "deprecated from payload, should be in URL routing, but included for backward compatibility." Contradicted the schema at mcp-webhook-payload.json which says publishers MUST echo this back so buyers correlate notifications without parsing URL paths. - Field already supported in the payload constructor; only the docstring needed correction. Adds a test confirming the end-to-end echo from send_mcp(operation_id=...) into the delivered payload. - Test: test_send_mcp_threads_operation_id_into_payload All 5 fixes ship together as a security-prep PR before the v6.0 DecisioningPlatform foundation work lands. Each is independent of the others; reviewers can evaluate by gap. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(signing): scope down per expert review Expert review of the prep PR (security-reviewer, code-reviewer, python-expert, ad-tech-protocol-expert) flagged 3 changes that should move to the foundation PR or be reversed: 1. JWKS tenant_id kwarg removed - Protocol expert: tenant_id is the wrong axis for JWKS isolation; AdCP anchors keys at agents[].jwks_uri, not at a seller-internal tenant_id. CachingJwksResolver(jwks_uri=...) already isolates by URL. - Security: the kwarg was opt-in with no enforcement signal — adopters who built a tenant-scoping resolver and forgot to thread tenant_id on every call would silently fall back to single-tenant. - Python: the JWKS document verifier (jws.py) didn't thread the kwarg, so the multi-tenant guarantee only held on the RFC 9421 path — undercutting the stated security improvement. - Code review: Protocol shape change with no compat shim was a breaking change for external resolver implementations. - Resolution: drop the kwarg from this prep PR. Reintroduce in the foundation PR with the spec-correct axis (likely (jwks_uri, key_id) or (agent_url, key_id)) and after the spec project clarifies multi-tenant key isolation guidance. 2. Port allowlist default flipped to permissive - Protocol expert: AdCP doesn't constrain pushNotificationConfig.url ports (push-notification-config.json:7-11). Defaulting to {443, 8443} silently rejects legitimate buyers on :9443 (Tomcat), :4443 (Spring Boot), or path-routed multi-tenant gateways. - Security M2: implicit-HTTP rejection (port 80) wasn't documented as scheme enforcement; adopters hitting it would widen the allowlist and re-enable plaintext. - Resolution: default allowed_ports=None (no port filter); operators opt INTO {443, 8443} hardening by passing allowed_ports=DEFAULT_ALLOWED_PORTS. The constant is exported from adcp.signing for adopters who want the recommended posture. The IP-range check + IP pinning still apply regardless of port — the smuggle vector to internal services on the same routable IP is closed by IP-range rejection, not by port enforcement. 3. covers_content_digest='required' regression test dropped - Protocol expert: AdCP 3.0 spec explicitly sets "default": "either" (get-adcp-capabilities-response.json:912-921) with the rationale "'required' is recommended for spend-committing operations in production; 4.0 recommends 'required' for those operations." - Existing code shipped "required" as the default before this PR — a pre-existing divergence from the spec that's not my PR's bug to pin. Drop the regression test that locked in the wrong default. File a separate issue to address the spec divergence. Also fixed (real bugs from the same review): 4. WebhookSender.from_jwk / from_pem now forward allow_private_destinations + allowed_destination_ports to the constructor. Documented happy-path adopters can now configure SSRF policy without dropping to __init__. 5. Replaced `assert self._client is not None` (mypy-narrowing) on the operator-supplied client path with an explicit RuntimeError. The state is reachable (aclose() then re-send) and python -O strips asserts, leaving the call to silently NoneType.post(). Net result for this PR (now 3 fixes instead of 5): - IP-pinned webhook delivery (the actual security hole) - Optional port allowlist as opt-in operator hardening - operation_id docstring fix (schema-mandated echo) Plus the from_jwk/from_pem ergonomics + assert-to-raise fix. Tests: 2254 passing locally. New tests: - test_jwks.py::test_ssrf_default_imposes_no_port_filter (parametrized) — confirms :9443/:4443/:8080/:80 all pass without explicit allowlist - test_jwks.py::test_ssrf_default_allowlist_passes_canonical_https_ports - test_jwks.py::test_ssrf_empty_allowlist_rejects_every_port (sentinel) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(signing): apply 2nd-pass review — trust_env, validate-before-sign, tests Second-pass expert review (security-reviewer, code-reviewer, python-expert, ad-tech-protocol-expert) on the scope-down commit 3fd2c49 surfaced 1 ship-blocker, 2 real bugs, 3 missing tests, and a nit. All addressed. Ship-blocker (security-reviewer): 1. WebhookSender's per-request httpx.AsyncClient missed trust_env=False. httpx defaults trust_env=True, which routes the signed webhook through any HTTPS_PROXY / HTTP_PROXY env var, bypassing the AsyncIpPinnedTransport entirely. Every other pinned-transport callsite in this codebase explicitly sets trust_env=False (default_jwks_fetcher, async_default_jwks_fetcher, revocation_fetcher); the webhook sender was the outlier. An attacker who controls process env (sidecar config, dotenv, malicious cluster egress policy) could otherwise pivot to receiving the signed webhook body. One-line fix at webhook_sender.py:577 with a regression test that asserts the kwarg is set on the per-request client. Bugs (python-expert): 2. Stale docstrings in build_ip_pinned_transport and build_async_ip_pinned_transport claimed allowed_ports defaults to DEFAULT_ALLOWED_PORTS ({443, 8443}) — but the scope-down flipped the default to None (no port filter). Adopters reading the docstring would hit confusing rejections. Updated both to describe the actual behavior. 3. _send_bytes signed the body before SSRF-validating the URL. Restructured so the pinned-transport build (which runs SSRF + port validation) happens first; signing only after validation succeeds. Hostile URLs no longer leave a signed payload in process memory for faulthandler / custom logging hooks to capture on exception. New regression tests (code-reviewer + security-reviewer): 4. test_owned_client_default_allows_non_standard_ports — sender-level positive analog of the validator-level test_ssrf_default_imposes_no_port_filter. Confirms the permissive port default reaches the actual delivery path; AdCP-spec-compliant buyers on :9443 (Tomcat) and similar non-standard ports succeed without explicit allowlist. 5. test_operator_supplied_client_bypasses_ssrf_guard — named regression guard for the documented contract. Without this, a future refactor that mistakenly applies pin-and-bind to both branches would break ASGI-based unit tests and any vetted-egress-proxy deployment that routes via private networks. 6. test_owned_client_ignores_https_proxy_env — regression guard for trust_env=False. Patches HTTPS_PROXY in env, asserts the per-request client constructs with trust_env=False so the proxy is ignored. Code-reviewer nit: 7. Deduplicated DEFAULT_ALLOWED_PORTS rationale block-comment between adcp.signing.jwks (constant definition) and tests/conformance/signing/test_jwks.py. Kept at the constant-definition site; test file points to it. Commit type changed from fix(signing) to feat(signing): The PR adds public surface (DEFAULT_ALLOWED_PORTS export, new kwargs on validate_jwks_uri / resolve_and_validate_host / build_*_pinned_transport / WebhookSender / from_jwk / from_pem) and changes WebhookSender._send_bytes behavior on the owned-client path (now SSRF-validates and pin-binds every delivery). Per semver, additive public-API surface = minor; the security-fix-via-strictening-default is also conventionally a minor bump. release-please should tag this as 4.1.0, not 4.0.1. If squash-merging, the maintainer should use a feat(signing): PR title so the squash subject carries the conventional-commit type that release-please reads. Tests: 2257 passing locally (3 new). Pre-commit clean (black, ruff, mypy, bandit). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(signing): tighten test claims and add validate-before-sign assertion Final-pass review on 50ae54d surfaced three test-quality nits, all addressed. 1. test_owned_client_default_allows_non_standard_ports docstring overclaim: the test patches build_async_ip_pinned_transport with a fake, so the IP-range check inside the real builder doesn't fire. Soften the claim and point at test_owned_client_rejects_loopback_destination for the IP-range coverage. 2. test_owned_client_ignores_https_proxy_env now also asserts "transport" in captured_kwargs. Without this, a future refactor that moves trust_env=False to the eager __aenter__ construction (away from the per-request construction where the proxy-bypass guard actually lives) would pass the test while leaving the per-request client vulnerable. 3. test_owned_client_rejects_hostile_url_before_signing: new test for the validate-before-sign claim made in the _send_bytes docstring. Patches sign_webhook to a MagicMock, points the URL at 127.0.0.1, asserts SSRFValidationError raises AND mock_sign.called is False. No Ed25519/ES256 signature ever materializes for a URL that fails the SSRF guard. Tests: 2258 passing locally (up from 2257). Pre-commit clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2efa423 commit 84b837e

8 files changed

Lines changed: 558 additions & 36 deletions

File tree

src/adcp/signing/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@
157157
build_ip_pinned_transport,
158158
)
159159
from adcp.signing.jwks import (
160+
DEFAULT_ALLOWED_PORTS,
160161
AsyncCachingJwksResolver,
161162
AsyncJwksFetcher,
162163
AsyncJwksResolver,
@@ -251,6 +252,7 @@ def __init__(self, *args: object, **kwargs: object) -> None:
251252
"CachingJwksResolver",
252253
"CachingRevocationChecker",
253254
"CapabilityProvider",
255+
"DEFAULT_ALLOWED_PORTS",
254256
"DEFAULT_EXPIRES_IN_SECONDS",
255257
"DEFAULT_GRACE_MULTIPLIER",
256258
"DEFAULT_SKEW_SECONDS",

src/adcp/signing/ip_pinned_transport.py

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -289,44 +289,65 @@ def build_ip_pinned_transport(
289289
uri: str,
290290
*,
291291
allow_private: bool = False,
292+
allowed_ports: frozenset[int] | None = None,
292293
verify: bool = True,
293294
) -> IpPinnedTransport:
294295
"""Resolve ``uri`` once and return a transport pinned to the validated IP.
295296
296297
Raises :class:`SSRFValidationError` if the URI's scheme isn't
297-
``http``/``https``, the host doesn't resolve, or every resolved
298-
IP is in a blocked range.
298+
``http``/``https``, ``allowed_ports`` is set and the URI's port is
299+
outside it, the host doesn't resolve, or every resolved IP is in a
300+
blocked range.
301+
302+
``allowed_ports`` defaults to ``None`` (no port filter — AdCP
303+
doesn't constrain webhook ports). Hardened deployments pass
304+
:data:`adcp.signing.jwks.DEFAULT_ALLOWED_PORTS` (`{443, 8443}`)
305+
or a custom set.
299306
300307
Typical use inside a fetcher::
301308
302309
transport = build_ip_pinned_transport(uri)
303310
with httpx.Client(transport=transport, timeout=10.0) as client:
304311
response = client.get(uri)
305312
"""
306-
hostname, resolved_ip, _port = resolve_and_validate_host(uri, allow_private=allow_private)
313+
hostname, resolved_ip, _port = resolve_and_validate_host(
314+
uri,
315+
allow_private=allow_private,
316+
allowed_ports=allowed_ports,
317+
)
307318
return IpPinnedTransport(hostname=hostname, resolved_ip=resolved_ip, verify=verify)
308319

309320

310321
def build_async_ip_pinned_transport(
311322
uri: str,
312323
*,
313324
allow_private: bool = False,
325+
allowed_ports: frozenset[int] | None = None,
314326
verify: bool = True,
315327
) -> AsyncIpPinnedTransport:
316328
"""Build an :class:`AsyncIpPinnedTransport` for ``uri``.
317329
318330
Resolve + validate run synchronously (``socket.getaddrinfo``); this
319331
function itself is not awaitable. The returned transport plugs
320332
into :class:`httpx.AsyncClient`.
333+
334+
``allowed_ports`` defaults to ``None`` (no port filter); see
335+
:func:`build_ip_pinned_transport` for the hardening kwarg
336+
semantics.
321337
"""
322-
hostname, resolved_ip, _port = resolve_and_validate_host(uri, allow_private=allow_private)
338+
hostname, resolved_ip, _port = resolve_and_validate_host(
339+
uri,
340+
allow_private=allow_private,
341+
allowed_ports=allowed_ports,
342+
)
323343
return AsyncIpPinnedTransport(hostname=hostname, resolved_ip=resolved_ip, verify=verify)
324344

325345

326346
def abuild_ip_pinned_transport(
327347
uri: str,
328348
*,
329349
allow_private: bool = False,
350+
allowed_ports: frozenset[int] | None = None,
330351
verify: bool = True,
331352
) -> AsyncIpPinnedTransport:
332353
"""Deprecated alias for :func:`build_async_ip_pinned_transport`.
@@ -343,4 +364,9 @@ def abuild_ip_pinned_transport(
343364
DeprecationWarning,
344365
stacklevel=2,
345366
)
346-
return build_async_ip_pinned_transport(uri, allow_private=allow_private, verify=verify)
367+
return build_async_ip_pinned_transport(
368+
uri,
369+
allow_private=allow_private,
370+
allowed_ports=allowed_ports,
371+
verify=verify,
372+
)

src/adcp/signing/jwks.py

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,17 @@
5353
}
5454
)
5555

56+
# Recommended destination ports for hardened SSRF-validated outbound HTTP
57+
# deployments. AdCP itself does not constrain ``pushNotificationConfig.url``
58+
# ports (see ``schemas/cache/core/push-notification-config.json``), so the
59+
# default port-allowlist is permissive — adopters who want a hardening posture
60+
# pass ``allowed_ports=DEFAULT_ALLOWED_PORTS`` (or a custom set) explicitly.
61+
# Rejecting non-standard ports closes a smuggle vector for buyers bouncing
62+
# traffic to internal services on the same routable IP — :25 (SMTP relay),
63+
# :6379 (Redis), :11211 (Memcached), etc. — but that's an operator choice,
64+
# not a framework default that breaks legitimate :9443 / :4443 buyers.
65+
DEFAULT_ALLOWED_PORTS: frozenset[int] = frozenset({443, 8443})
66+
5667
# Upper bound on the number of resolved addresses examined per validation call.
5768
# A malicious DNS server can return thousands of records as a mild amplification
5869
# vector against the validator's inner loop.
@@ -104,20 +115,26 @@ class AsyncJwksResolver(Protocol):
104115
async def __call__(self, keyid: str) -> dict[str, Any] | None: ...
105116

106117

107-
def validate_jwks_uri(uri: str, *, allow_private: bool = False) -> None:
108-
"""Raise SSRFValidationError if `uri` resolves to a blocked IP or has a bad scheme.
118+
def validate_jwks_uri(
119+
uri: str,
120+
*,
121+
allow_private: bool = False,
122+
allowed_ports: frozenset[int] | None = None,
123+
) -> None:
124+
"""Raise SSRFValidationError on blocked IP, bad scheme, or disallowed port.
109125
110-
This is kept as a standalone no-return helper for callers that only
111-
want validation — :func:`resolve_and_validate_host` returns the
112-
accepted IP when the caller needs it for IP-pinned connects.
126+
Standalone no-return helper for callers that only want validation —
127+
:func:`resolve_and_validate_host` returns the accepted IP when the
128+
caller needs it for IP-pinned connects.
113129
"""
114-
resolve_and_validate_host(uri, allow_private=allow_private)
130+
resolve_and_validate_host(uri, allow_private=allow_private, allowed_ports=allowed_ports)
115131

116132

117133
def resolve_and_validate_host(
118134
uri: str,
119135
*,
120136
allow_private: bool = False,
137+
allowed_ports: frozenset[int] | None = None,
121138
) -> tuple[str, str, int]:
122139
"""Resolve the URI's hostname once and return ``(hostname, ip, port)``.
123140
@@ -138,6 +155,13 @@ def resolve_and_validate_host(
138155
allow_private:
139156
Skip the reserved-range check. For tests only; cloud-metadata
140157
IPs remain blocked unconditionally.
158+
allowed_ports:
159+
Optional destination-port allowlist. ``None`` (default) imposes
160+
no port filter — the URL's port is unrestricted. Hardened
161+
deployments pass :data:`DEFAULT_ALLOWED_PORTS` (`{443, 8443}`)
162+
or a custom set; the validator then rejects URIs whose port
163+
is outside the set. AdCP doesn't constrain webhook ports in
164+
the spec, so this is operator policy, not a framework default.
141165
142166
Returns
143167
-------
@@ -148,8 +172,9 @@ def resolve_and_validate_host(
148172
Raises
149173
------
150174
SSRFValidationError
151-
Scheme is not ``http``/``https``, the hostname doesn't resolve,
152-
or every resolved IP is in a blocked range.
175+
Scheme is not ``http``/``https``, ``allowed_ports`` is set and
176+
the URI's port is outside it, the hostname doesn't resolve, or
177+
every resolved IP is in a blocked range.
153178
"""
154179
parts = urlsplit(uri)
155180
if parts.scheme not in ("http", "https"):
@@ -177,6 +202,11 @@ def resolve_and_validate_host(
177202
except (UnicodeError, UnicodeEncodeError) as exc:
178203
raise SSRFValidationError(f"URI host {host!r} is not IDNA-valid: {exc}") from exc
179204
port = parts.port if parts.port is not None else (443 if parts.scheme == "https" else 80)
205+
if allowed_ports is not None and port not in allowed_ports:
206+
raise SSRFValidationError(
207+
f"port {port} not allowed for SSRF-validated fetch "
208+
f"(allowed: {sorted(allowed_ports) if allowed_ports else '<empty>'})"
209+
)
180210

181211
try:
182212
infos = socket.getaddrinfo(host, None)
@@ -461,6 +491,7 @@ async def resolve(keyid: str) -> dict[str, Any] | None:
461491
"AsyncJwksFetcher",
462492
"AsyncJwksResolver",
463493
"CachingJwksResolver",
494+
"DEFAULT_ALLOWED_PORTS",
464495
"DEFAULT_JWKS_COOLDOWN_SECONDS",
465496
"DEFAULT_JWKS_TIMEOUT_SECONDS",
466497
"JwksFetcher",

src/adcp/webhook_sender.py

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@
4444
load_private_key_pem,
4545
private_key_from_jwk,
4646
)
47+
from adcp.signing.ip_pinned_transport import (
48+
AsyncIpPinnedTransport,
49+
build_async_ip_pinned_transport,
50+
)
4751
from adcp.signing.webhook_signer import sign_webhook
4852
from adcp.types import GeneratedTaskStatus
4953
from adcp.types.generated_poc.core.async_response_data import AdcpAsyncResponseData
@@ -112,13 +116,17 @@ def __init__(
112116
alg: str,
113117
client: httpx.AsyncClient | None = None,
114118
timeout_seconds: float = _DEFAULT_TIMEOUT_SECONDS,
119+
allow_private_destinations: bool = False,
120+
allowed_destination_ports: frozenset[int] | None = None,
115121
) -> None:
116122
self._private_key = private_key
117123
self._key_id = key_id
118124
self._alg = alg
119125
self._timeout = timeout_seconds
120126
self._client = client
121127
self._owns_client = client is None
128+
self._allow_private_destinations = allow_private_destinations
129+
self._allowed_destination_ports = allowed_destination_ports
122130

123131
@classmethod
124132
def from_jwk(
@@ -128,13 +136,18 @@ def from_jwk(
128136
d_field: str = "d",
129137
client: httpx.AsyncClient | None = None,
130138
timeout_seconds: float = _DEFAULT_TIMEOUT_SECONDS,
139+
allow_private_destinations: bool = False,
140+
allowed_destination_ports: frozenset[int] | None = None,
131141
) -> WebhookSender:
132142
"""Construct from a JWK that includes the private scalar.
133143
134144
The JWK MUST have ``adcp_use == "webhook-signing"`` — the sender
135145
doesn't validate this (you're signing with your own key; validation
136146
happens at the receiver), but a key whose adcp_use is wrong will be
137147
rejected by every conformant verifier.
148+
149+
``allow_private_destinations`` and ``allowed_destination_ports``
150+
forward to :meth:`__init__` — see that signature for semantics.
138151
"""
139152
# Snapshot the mapping once — a live Mapping could otherwise return
140153
# different values across the adcp_use / kid / d / alg reads.
@@ -163,6 +176,8 @@ def from_jwk(
163176
alg=alg,
164177
client=client,
165178
timeout_seconds=timeout_seconds,
179+
allow_private_destinations=allow_private_destinations,
180+
allowed_destination_ports=allowed_destination_ports,
166181
)
167182

168183
@classmethod
@@ -175,6 +190,8 @@ def from_pem(
175190
passphrase: bytes | None = None,
176191
client: httpx.AsyncClient | None = None,
177192
timeout_seconds: float = _DEFAULT_TIMEOUT_SECONDS,
193+
allow_private_destinations: bool = False,
194+
allowed_destination_ports: frozenset[int] | None = None,
178195
) -> WebhookSender:
179196
"""Load a private key from a PEM file and bind it as a webhook sender.
180197
@@ -194,6 +211,8 @@ def from_pem(
194211
client: Optional pre-built :class:`httpx.AsyncClient` to share
195212
across the SDK; the sender owns its own client when omitted.
196213
timeout_seconds: Per-request timeout for the owned client.
214+
allow_private_destinations: Forwarded to :meth:`__init__`.
215+
allowed_destination_ports: Forwarded to :meth:`__init__`.
197216
198217
Raises:
199218
ValueError: ``alg`` is not ed25519 / es256, or the PEM contains
@@ -239,6 +258,8 @@ def from_pem(
239258
alg=alg,
240259
client=client,
241260
timeout_seconds=timeout_seconds,
261+
allow_private_destinations=allow_private_destinations,
262+
allowed_destination_ports=allowed_destination_ports,
242263
)
243264

244265
def __repr__(self) -> str:
@@ -471,7 +492,42 @@ async def _send_bytes(
471492
idempotency_key: str,
472493
extra_headers: Mapping[str, str] | None,
473494
) -> WebhookDeliveryResult:
474-
"""Sign + POST a pre-serialized body. Shared by send_raw and resend."""
495+
"""Sign + POST a pre-serialized body through an SSRF-validated transport.
496+
497+
When the sender owns its httpx client (the default — ``client=None``
498+
was passed to ``__init__``), every delivery builds a per-request
499+
:class:`adcp.signing.ip_pinned_transport.AsyncIpPinnedTransport`
500+
that resolves the destination, runs the full SSRF range check
501+
(loopback / RFC 1918 / link-local / CGNAT / IPv6 ULA / multicast /
502+
cloud metadata), enforces the port allowlist, and pins the
503+
connection to the validated IP. This closes the DNS-rebinding
504+
TOCTOU between validate and connect.
505+
506+
When the operator supplied their own client
507+
(``WebhookSender(client=...)`` — typically a vetted egress proxy
508+
with mTLS to a known buyer set, or an ASGI transport for testing),
509+
the sender trusts the operator's transport completely. Pin-and-bind
510+
is skipped; the operator's transport owns SSRF.
511+
512+
On the owned-client path, SSRF validation runs **before** signing
513+
so a hostile URL is rejected without first generating an
514+
Ed25519/ES256 signature over the body. That signature would
515+
otherwise sit in process memory until the SSRF rejection —
516+
anything that snapshots locals on exception (faulthandler,
517+
custom logging) could capture it. Validate first, sign second.
518+
"""
519+
# Build the pinned transport up-front for the owned-client path.
520+
# This runs SSRF + port validation against the URL before any
521+
# signing happens; a hostile URL raises SSRFValidationError here
522+
# and the body never gets signed.
523+
transport: AsyncIpPinnedTransport | None = None
524+
if self._owns_client:
525+
transport = build_async_ip_pinned_transport(
526+
url,
527+
allow_private=self._allow_private_destinations,
528+
allowed_ports=self._allowed_destination_ports,
529+
)
530+
475531
base_headers = {"Content-Type": "application/json"}
476532
signed = sign_webhook(
477533
method="POST",
@@ -495,8 +551,33 @@ async def _send_bytes(
495551
for k, v in extra_headers.items():
496552
headers[k] = v
497553

498-
client = await self._get_client()
499-
response = await client.post(url, content=body, headers=headers)
554+
if transport is not None:
555+
# Owned-client path. ``trust_env=False`` prevents httpx from
556+
# routing the request through ``HTTPS_PROXY`` / ``HTTP_PROXY``
557+
# env vars — every other pinned-transport callsite in the
558+
# codebase sets this for the same reason (default_jwks_fetcher,
559+
# async_default_jwks_fetcher, revocation_fetcher). Without it,
560+
# an attacker who controls process env can route the signed
561+
# webhook through their endpoint, defeating the IP pin entirely.
562+
async with httpx.AsyncClient(
563+
transport=transport,
564+
timeout=self._timeout,
565+
follow_redirects=False,
566+
trust_env=False,
567+
) as client:
568+
response = await client.post(url, content=body, headers=headers)
569+
else:
570+
# Operator-supplied client — they own the SSRF guarantees on
571+
# their transport (proxy allowlist, mTLS, etc.). Reachable as
572+
# None after aclose(); explicit raise survives ``python -O``
573+
# which would strip an assert.
574+
if self._client is None:
575+
raise RuntimeError(
576+
"WebhookSender's operator-supplied client was already "
577+
"closed. Construct a new sender or pass a fresh client."
578+
)
579+
response = await self._client.post(url, content=body, headers=headers)
580+
500581
return WebhookDeliveryResult(
501582
status_code=response.status_code,
502583
idempotency_key=idempotency_key,

src/adcp/webhooks.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,12 @@ def create_mcp_webhook_payload(
107107
task_type: Optionally type of AdCP operation (e.g., "get_products", "create_media_buy")
108108
timestamp: When the webhook was generated (defaults to current UTC time)
109109
result: Task-specific payload (AdCP response data)
110-
operation_id: Publisher-defined operation identifier (deprecated from payload,
111-
should be in URL routing, but included for backward compatibility)
110+
operation_id: Client-generated identifier the buyer embedded in the
111+
webhook URL when registering push-notification config. Publishers
112+
MUST echo this back in the payload so buyers correlate notifications
113+
without parsing URL paths (per ``mcp-webhook-payload.json``).
114+
Senders extracting the value from the URL path on emission populate
115+
this field; callers constructing payloads directly pass it through.
112116
message: Human-readable summary of task state
113117
context_id: Session/conversation identifier
114118
domain: AdCP domain this task belongs to

0 commit comments

Comments
 (0)