Skip to content

feat: add webhook URL policy and wholesale feed sender#833

Merged
bokelley merged 2 commits into
mainfrom
bokelley/webhook-url-policy-helper
May 23, 2026
Merged

feat: add webhook URL policy and wholesale feed sender#833
bokelley merged 2 commits into
mainfrom
bokelley/webhook-url-policy-helper

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

@bokelley bokelley commented May 23, 2026

Summary

  • add a stable registration-time webhook destination policy helper for push_notification_config.url and accounts[].notification_configs[].url
  • export NotificationConfig, WholesaleFeedEvent, and WholesaleFeedWebhook from stable adcp and adcp.types paths
  • add WebhookSender.send_wholesale_feed(...) and send_wholesale_feed_to_subscription(...) for signed wholesale product/signal feed notifications with event/account/subscriber validation
  • document seller-side URL validation and wholesale feed notification sending

Why

Downstream sellers need to reject unsafe durable webhook subscriptions before storing them, without importing JWKS-named internals or reimplementing SSRF policy locally. Sellers also need stable AdCP 3.1 catalog notification types and a typed sender path instead of hand-building wholesale feed envelopes around send_raw(...).

Validation

  • PYTHONPATH=src pytest tests/test_webhook_destination_policy.py tests/test_wholesale_feed_webhook_sender.py tests/test_public_api.py tests/test_catalog_types.py tests/test_webhooks_deliver.py tests/conformance/signing/test_webhook_sender_e2e.py tests/conformance/signing/test_webhook_sender_alt_auth.py -q
  • PYTHONPATH=src python3 -m ruff check src/adcp/webhooks.py src/adcp/webhook_sender.py src/adcp/types/__init__.py src/adcp/__init__.py tests/test_webhook_destination_policy.py tests/test_wholesale_feed_webhook_sender.py tests/test_public_api.py
  • PYTHONPATH=src python3 -m mypy src/adcp/webhooks.py src/adcp/webhook_sender.py
  • git diff --check

@bokelley bokelley force-pushed the bokelley/webhook-url-policy-helper branch from c381d06 to 24e3ea3 Compare May 23, 2026 16:00
@bokelley bokelley changed the title [codex] add webhook URL policy and wholesale feed sender feat: add webhook URL policy and wholesale feed sender May 23, 2026
@bokelley bokelley marked this pull request as ready for review May 23, 2026 16:03
@aao-ipr-bot
Copy link
Copy Markdown
Contributor

aao-ipr-bot Bot commented May 23, 2026

⚠️ Argus review could not complete

The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final gh pr review). A human reviewer should take this PR.

View workflow run

This is an automated message from the Argus AI review workflow.

@bokelley bokelley enabled auto-merge (squash) May 23, 2026 21:08
@bokelley bokelley disabled auto-merge May 23, 2026 21:08
Copy link
Copy Markdown
Contributor

@aao-ipr-bot aao-ipr-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Clean fix. Right shape — registration-time policy is the same SSRF classifier as WebhookSender, just hoisted to a typed pre-store guard so sellers can fail closed without importing JWKS internals.

Things I checked

  • SSRF defense ordering at src/adcp/webhooks.py:965-1057: control-char input → apply_hooks → post-hook control-char re-check → userinfo → fragment → scheme → require_https → resolve_and_validate_host. Right order. Fragment is rejected before DNS — proven by the fail_getaddrinfo poison-pill at tests/test_webhook_destination_policy.py:84-93.
  • Post-hook control-char re-check at webhooks.py:986-994 is load-bearing — apply_hooks doesn't strip CRLF that a custom transport hook could inject.
  • DNS rebinding closed at send time: WebhookSender._send_bytes rebuilds a fresh AsyncIpPinnedTransport via build_async_ip_pinned_transport(effective_url, ...) on every delivery, so the helper's resolved_ip is informational, not authoritative. signing/jwks.py:266-267 blocks cloud-metadata IPs before the allow_private gate — test_local_development_still_rejects_cloud_metadata confirms.
  • WebhookDestinationValidationError.to_error() at webhooks.py:893-901 returns only {code, message, field?, suggestion?}. effective_url does not leak through.
  • Wire envelope correct: WholesaleFeedWebhook envelope at webhook_sender.py:778-791 maps notification_id ← event.event_id per wholesale_feed_webhook.py description ("MUST equal event.event_id"). cache_scope == event.payload.applies_to.scope coupling at webhook_sender.py:768-774 mirrors the schema constraint.
  • _entity_type_for_wholesale_notification at webhook_sender.py:131-141 covers all nine upstream notification types (product.*product, signal.*signal, wholesale_feed.bulk_changefeed). Complete partition against EntityType.
  • Exports are additive only — __init__.py and tests/fixtures/public_api_snapshot.json diffs are all +. feat: is the right semver.
  • No # type: ignore, no test skips, no disabled CI checks in the diff.

Follow-ups (non-blocking — file as issues)

  • extra_headers is unvalidated on the WebhookSender path. Pre-existing — _send_bytes only runs merge_extra_headers's reserved-key check, not the _validate_header_value + _MAX_EXTRA_HEADERS cap that the legacy deliver() in webhooks.py:1268-1281 enforces. The new send_wholesale_feed* methods inherit this gap and widen the surface — wholesale-catalog notifications are exactly where sellers will want to echo buyer-controlled correlation IDs into X-Buyer-Trace-Id. Fix at the helper boundary (_send_bytes), not per method. From security-reviewer.
  • subscription_event_types is opt-in on the low-level method. send_wholesale_feed_to_subscription always passes it through, but the lower-level entry point treats the subscription filter as optional ("optional but recommended" at webhook_sender.py:726-729). The schema description on notification_config.event_types says sellers MUST NOT fire other types against the endpoint. Consider tightening to required, or at minimum log when absent. From ad-tech-protocol-expert.
  • WholesaleFeedEvent has no open-union forward-compat. Unlike Format.assets (patched via _forward_compat.py), the WholesaleFeedEvent discriminator is strict. If AdCP 3.2 adds a tenth event type, this client hard-fails on receive. Worth confirming the upstream contract — the spec text near wholesale_feed_event.py:656 already says consumers MUST tolerate unknown recommendation enum values, hinting at the same posture for event_type. From ad-tech-protocol-expert.

Minor nits (non-blocking)

  1. _validate_policy_hooks lets hook ValueError propagate raw. src/adcp/webhooks.py:925-929. Callers that catch only WebhookDestinationValidationError to map to INVALID_REQUEST will miss this. Wrap in WebhookDestinationValidationError(reason="transport_hook_misconfigured", ...) for consistency. Arguably this is dev misconfiguration at construction time, not buyer input — defensible to leave.
  2. Tests skip a few negative branches. tests/test_wholesale_feed_webhook_sender.py has no negative case for cache_scope mismatch (webhook_sender.py:770) or entity_type mismatch (webhook_sender.py:762). tests/test_webhook_destination_policy.py has no negative case for embedded userinfo (webhooks.py:997-1006) or post-hook control-char re-check (webhooks.py:986-994). Happy paths plus the user's stated checklist are covered; SSRF-adjacent branches deserve a negative each.
  3. subscriber_id validation is looser than the schema. webhook_sender.py:732 accepts any non-empty string; schema requires ^[A-Za-z0-9_.:-]{1,64}$. Pydantic catches it on model_validate at line 778, but the failure surface is a ValidationError rather than the sender's own ValueError — inconsistent with the explicit pre-checks for account_id/wholesale_feed_version. Either delete the three pre-checks (let Pydantic enforce) or add the regex here for symmetry. Same for idempotency_key (^[A-Za-z0-9_.:-]{16,255}$).

The lower-level entry point makes the "do not silently widen account notification filters" contract opt-in via subscription_event_types=None, and the schema says MUST — notable choice for the public surface.

LGTM. Follow-ups noted below.

@bokelley bokelley merged commit 65f84d2 into main May 23, 2026
23 checks passed
@bokelley bokelley deleted the bokelley/webhook-url-policy-helper branch May 23, 2026 21:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant