feat: add webhook proof-of-control helper#843
Conversation
There was a problem hiding this comment.
LGTM. Clean fix for #839 with the right shape: a registration-time helper that layers on top of validate_webhook_destination_url's SSRF guard, refuses operator-supplied client= / sender transport_hooks, and surfaces typed WebhookChallengeErrors sellers can map to INVALID_REQUEST in sync_accounts errors.
Things I checked
- SSRF guard is intact end-to-end.
validate_webhook_destination_urlatsrc/adcp/webhooks.py:1377returnsdestination.effective_url; same string is passed to bothbuild_async_ip_pinned_transportandhttp_client.postin_send_legacy_webhook_challenge(src/adcp/webhooks.py:1237,:1283).follow_redirects=False+trust_env=Falseboth set. - Reserved-header tightening at
src/adcp/webhook_auth.py:52(content-length,host) plus pseudo-header rejection atwebhook_auth.py:206-209._RESERVED_HEADERSatwebhooks.py:769-781already coversauthorization,signature,signature-input,content-digest,x-adcp-signature,x-adcp-timestamp— auth-binding surface is complete. - No credential leak in
WebhookChallengeError.to_error()atwebhooks.py:943-951— emits only{code, message, field, suggestion}. Bearer/HMAC value is interpolated only into outgoingAuthorization/X-AdCP-Signatureheaders (webhooks.py:1252,:1254), never into raised messages. - Sender hardening rationale (
webhooks.py:1334-1349) is load-bearing: a sender-ownedclient=bypasses the pinned-transport path, and a sendertransport_hookcould swap to a different validated host aftervalidate_webhook_destination_urlhas already pinned the original IP. For proof-of-control the pinned IP must match the URL the seller is about to persist. - Public API: pure additions to
src/adcp/__init__.py(6 new exports) with matching snapshot bump intests/fixtures/public_api_snapshot.json.feat:is the right conventional-commit prefix — no removal, no signature change.
Follow-ups (non-blocking — file as issues)
- TOCTOU:
SSRFValidationErrorescapes the wrapper._send_legacy_webhook_challengeatwebhooks.py:1237builds the pinned transport inside the secondtryblock atwebhooks.py:1400-1448.SSRFValidationErrordoes not subclassValueError/httpx.HTTPError/httpx.TimeoutException, so a DNS-rebind race betweenvalidate_webhook_destination_urland connect raises an unwrappedSSRFValidationErrorinstead of a typedWebhookChallengeError(reason="ssrf_rejected"). Security still holds — the SSRF check fires — but the typed error contract leaks. Add anexcept SSRFValidationErrorarm. - Spec the wire shape upstream.
ad-tech-protocol-expertflagged:schemas/cache/3.1.0-beta.3/core/notification-config.json:17calls for "an activation challenge or equivalent proof-of-control" but does not pin a payload shape,typediscriminator value, or echo field. This SDK is effectively defining the convention. Before durable adoption, file an AdCP spec PR pinning (a)type: "webhook.challenge", (b) required fields, (c)challengeas canonical withtokenas deprecated alias. Otherwise the next SDK invents its own shape and sellers reject each other's challenges. - Test coverage gaps in
tests/test_webhook_challenge.py:httpx.TimeoutException→reason="timeout",httpx.HTTPError(non-timeout, non-status) →reason="request_failed", invalid JSON body →reason="invalid_json", non-mapping JSON (b'[1,2,3]') →reason="invalid_json", missing-echo (body with neither key) →reason="missing_echo", plusambiguous_auth_mode,ambiguous_client,ambiguous_timeout,sender_requiredguards are unhit. Onlychallenge_mismatchandhttp_statusexercised end-to-end.
Minor nits (non-blocking)
hmac.compare_digestfor echo check.webhooks.py:1019uses plain==. Not exploitable —secrets.token_urlsafe(32)gives 256 bits of single-use entropy — but every other secret-shaped comparison in the signing stack useshmac.compare_digest. Cheap consistency win.- Private-attribute brittleness.
webhooks.py:1334-1335reads_owns_client/_transport_hooksviagetattr(cast(Any, sender), ..., default). A futureWebhookSendersubclass that drops those attributes silently passes the proof-of-control safety check (fails open). Promote to a publicproof_of_control_safeproperty onWebhookSenderso subclassing fails closed. transfer-encodingnot in_BASE_RESERVED.webhook_auth.py:52. httpx blocks it at the transport, but the comment at_validate_header_valuesays the helper boundary is meant to be self-sufficient. Add for consistency.- Reserved-header tightening is silently breaking-adjacent. Adding
host,content-lengthto_BASE_RESERVEDwill start rejectingHost/Content-Lengthfrom any pre-existing caller passing them tosend_raw/send_mcp/ etc. Right call — overridingHostdefeats SSRF — but worth a release-note line so adopters know to migrate. - Deprecation-warning order.
webhooks.py:1231fires_warn_auth_deprecation_once()before validatingextra_headersat:1267-1273. A caller passing an invalid header still emits the deprecation warning. Reorder.
Approving on the strength of the SSRF / pinned-transport / sender-hardening invariants holding end-to-end. The TOCTOU wrapper gap and the spec-upstream work are real but neither blocks ship.
There was a problem hiding this comment.
Approving. Fail-closed sender contract (no operator-supplied client, no transport hooks, RFC 9421 only) plus the post-hook signature-target consistency makes this safe to ship.
Things I checked
- SSRF / DNS-rebind TOCTOU.
_post_managed_webhook_challengeposts todestination.effective_urland builds a freshbuild_async_ip_pinned_transportagainst it. The connect-time IP pin is the load-bearing guarantee, not the validator's first resolution.src/adcp/webhooks.py:1325-1337. - Signature target equals connect target. Both
_send_sender_webhook_challenge:1306and_send_legacy_webhook_challenge:1272sign + POSTdestination.effective_url(post-hook). A rewrite cannot land the signed body elsewhere. - Host / pseudo-header override closed.
_BASE_RESERVEDinsrc/adcp/webhook_auth.py:52addscontent-length, host;merge_extra_headers:206-207rejects HTTP/2 pseudo-headers (:authority). Asserted attests/test_webhook_challenge.py:356-359. trust_env=False, follow_redirects=Falseatwebhooks.py:1334-1335— closes env-proxy and 3xx-exfil paths.- No credentials in the challenge body.
create_webhook_challenge_payload:961-981emits only{type, challenge, account_id, subscriber_id}. send_webhook_challenge(sender helper) is wire-clean. Body is pre-serialized withoutidempotency_key;_send_bytesdoes not set anIdempotency-Keyheader. The challenge value only surfaces onWebhookDeliveryResult.idempotency_keyfor caller traceability.- Public-API snapshot is additions-only.
validate_webhook_destination_url'sstr → str | AnyUrlis a covariant input widening.feat:is the right semver signal.
Follow-ups (non-blocking — file as issues)
- Pin the wire shape in the spec.
core/notification-config.jsonsays only "activation challenge or equivalent proof-of-control."type: "webhook.challenge"and thechallenge/tokenecho-key tolerance are this SDK's calls. The dotted reverse-DNS discriminator value is also notable — every other AdCP envelope I checked uses snake_casenotification_type/task_type. File an AdCP RFC pinning the discriminator + echo key, align with the JS SDK, and mark this helper provisional in the docstring until then. - Preflight ordering puts DNS before payload validation.
challenge_webhook_destination:1410-1416runsvalidate_webhook_destination_urlbeforecreate_webhook_challenge_payload. Soaccount_id="" / subscriber_id="" / challenge=""callers pay the resolution round-trip before hittinginvalid_configuration.tests/test_webhook_challenge.py:1284reads green only because_public_dns(monkeypatch)stubs DNS. Reorder: build the payload first, validate the URL second. _validate_header_value:1980doesn't catch Unicode whitespace. The boundary contract advertises broad control-char rejection but only forbids\r\n\x00. The legacy Bearer/HMAC credential path runs values through this gate atwebhooks.py:1237. U+00A0 encodes cleanly to one latin-1 byte. Not header injection — but tighten tovalue.encode("latin-1")round-trip orvalue.isprintable()to match the docstring._BASE_RESERVEDwidening is a behavior change shipped underfeat:. Adopters passingextra_headers={"Host": "..."}to anyWebhookSender.send_*now raiseValueError. Hardening is right; call it out in the release notes when the version cuts.- Test coverage gaps. No coverage for
missing_echo,invalid_json,timeout,ambiguous_auth_mode, orsender_required. Each is ~6 lines.
Minor nits (non-blocking)
- Sender-internals access via
getattr/cast(Any, sender).webhooks.py:1297, 1373-1374, 1439reach intosender._auth / _owns_client / _transport_hooks / _timeout. A rename insidewebhook_sender.py:221-225silently breaks the helper at runtime. Either expose read-only properties onWebhookSender, or move the helper intowebhook_senderso the access is intra-module. error_url=""on early failures.webhooks.py:1365:str("") == "", soambiguous_auth_mode/sender_requirederrors surfaceurl=""rather thanNone. Cosmetic.
LGTM. Follow-ups noted below.
There was a problem hiding this comment.
Approving. Fail-closed sender mode guards plus SSRF-pinned transport on every challenge path make this the right shape for proof-of-control.
Things I checked
- Sender preconditions at
src/adcp/webhooks.py:1366-1409reject all five unsafe combinations: ambiguous auth mode, customclient=, sendertransport_hooks, non-RFC9421 sender, and the both-None case. No flag bypasses the IP pin. - Both challenge paths route through the tightened reserved-header set.
_BASE_RESERVEDatwebhook_auth.py:52now includeshost,content-length; pseudo-header reject (:authority) atwebhook_auth.py:207.test_challenge_webhook_destination_rejects_sender_host_overridecovers it. follow_redirects=False, trust_env=Falseatwebhooks.py:1334-1335blocks redirect-to-metadata pivots and proxy exfil of the legacy credential.secrets.token_urlsafe(32)at:958— 256 bits, adequate.wch_prefix is fine.WebhookChallengeErrorcarriescode/message/reason/field/url/status_code/suggestion; none echo the Bearer/HMAC credential._validate_header_valueat:1237rejects CR/LF before the credential reaches a header.- Conventional-commit
feat:is correct — additive exports only.validate_webhook_destination_urlwidening fromstrtostr | AnyUrlat:1060is parameter widening, backward-compatible. tests/fixtures/public_api_snapshot.jsonupdated for new exports; no drift.
Follow-ups (non-blocking — file as issues)
- Unbounded response body read.
webhooks.py:1451, 1464callsresponse.contentagainst the buyer-controlled URL with no size cap. The 10s timeout bounds latency but not bytes; a buyer pointingnotification_configs[].urlat a multi-GB endpoint can OOM the seller during challenge. Outbound has_MAX_BODY_BYTES = 10 * 1024 * 1024at:790; inbound should mirror it. Stream the response with a running cap and raiseWebhookChallengeError(reason=\"response_too_large\"). Security medium. - Echo field ambiguity.
validate_webhook_challenge_response:1018accepts eitherchallengeortoken. Two field names creates a permanent ambiguity matrix — every future receiver picks one, senders must support both forever. PubSubHubbub, Slackurl_verification, Stripe all converge on a single field. Resolve tochallenge, treattokenas a deprecation-warning interop shim, before this hardens into the de facto Python wire shape. - Challenge body carries
account_idandsubscriber_id.create_webhook_challenge_payload:976-981. Proof-of-control orthodoxy sends the nonce only — including IDs tempts receivers to validate against local state (wrong: the only invariant a challenge proves is URL control) and leaks ID-shape to URLs that do not yet have a seller relationship. - Discriminator divergence.
:977emits\"type\": \"webhook.challenge\", but every other AdCP webhook in this repo discriminates onnotification_type. Either align or RFC the new top-level discriminator upstream — silent divergence is the wrong outcome for the Python reference implementation. - Blanket
INVALID_REQUESTfor transport errors.WebhookChallengeError.code = \"INVALID_REQUEST\"at:925maps every failure —timeout,request_failed,ssrf_rejected,http_status— to the buyer-input error bucket. Transport failures are not buyer-input issues. Derivecodefromself.reason:timeout/request_failed→UPSTREAM_UNAVAILABLE,unsafe_sender_client/ambiguous_auth_mode→ seller-config error. - Private-attribute access into
WebhookSender._send_sender_webhook_challenge:1297(_auth),:1373(_owns_client),:1374(_transport_hooks),:1439(_timeout). Four sibling-module private attrs with no contract — a refactor inwebhook_sender.pyAttributeErrors here. Expose typed properties or an internal_proof_of_control_handle(). except ValueErrorswallowsWebhookChallengeError.webhooks.py:1479. SinceWebhookChallengeErrorinheritsValueError, any future validation inside_send_*_webhook_challengethat raisesWebhookChallengeErrorgets re-wrapped withreason=\"invalid_configuration\", masking the originalreason. Narrow the catch or drop theValueErrorbase.
Minor nits (non-blocking)
- Constant-time echo compare.
webhooks.py:1020usesvalue == challenge. Single-use 256-bit token, seller-side compare — not exploitable in practice.hmac.compare_digest(str(value), challenge)is the defense-in-depth one-liner. send_webhook_challengereturns a label, not a replay key.webhook_sender.py:866-872passeschallenge_valueasidempotency_keyto_send_bytes, but the challenge body intentionally omits theidempotency_keyfield. The returnedWebhookDeliveryResult.idempotency_keyis therefore a log label — a caller who feeds it intoresend()will not dedupe on the receiver. Worth one docstring sentence.error_urlnot scrubbed for embedded userinfo.webhooks.py:1365computeserror_url = str(url)beforevalidate_webhook_destination_urlstripsuser:pass@. The early sender-mode guards bake the unscrubbed URL intoWebhookChallengeError.url. Buyer's own credential, so self-leak, but worth sanitizing at the boundary.
The three-commit arc (b1e5629f → 10e45810 → 5242f7cb) lands fine — the unreachable guard at the end was load-bearing for mypy in one revision and dead in the next.
LGTM. Follow-ups noted below.
Summary
notification_configs[]WebhookSenderchallenges plus legacy Bearer/HMAC authenticationchallengeortoken, and surface typed errors for seller handlersHost,Content-Length, auth, or signature-binding headersCloses #839.
Validation
PYTHONPATH=src python3 -m ruff check src/PYTHONPATH=src python3 -m mypy src/adcp/PYTHONPATH=src python3 -m mypy --strict tests/type_checks/PYTHONPATH=src python3 -m pytest tests/ -q