Skip to content

feat(signing): drain three foundation-audit deferreds (#298, #299, #300)#303

Merged
bokelley merged 3 commits intomainfrom
bokelley/signing-prep-followups
Apr 29, 2026
Merged

feat(signing): drain three foundation-audit deferreds (#298, #299, #300)#303
bokelley merged 3 commits intomainfrom
bokelley/signing-prep-followups

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

Summary

Drains the three deferred items from PR #297's expert review. Each landed as a separate commit so reviewers can evaluate by issue.

Issue Commit Type
#298 0f9c3607fix(signing): default covers_content_digest to spec-mandated 'either' spec-alignment
#300 999ed639chore(signing): skip eager _get_client in WebhookSender.__aenter__ cleanup
#299 71626766feat(signing): wire SSRF guard into legacy webhooks.deliver() security

Behavior changes

Test plan

  • pytest tests/conformance/signing/ — 422 passing
  • pytest tests/test_webhooks_deliver.py — 29 passing (3 new SSRF regression tests)
  • pytest tests/ — 2266 passing locally
  • mypy src/adcp/ — clean
  • ruff check on changed files — clean
  • black --check on changed files — clean
  • CI on 3.10/3.11/3.12/3.13

Semver

Title feat(signing): so release-please tags this as the next minor bump after 4.1.0:

If squash-merging, the feat(signing): PR title carries the conventional-commit type that release-please reads.

Closes

🤖 Generated with Claude Code

bokelley and others added 3 commits April 29, 2026 19:44
Closes #298.

The AdCP 3.0 schema (`schemas/cache/protocol/get-adcp-capabilities-response.json:912-921`)
declares `covers_content_digest` default as "either", with the rationale:
"'required' is recommended for spend-committing operations in
production; 4.0 recommends 'required' for those operations."

`VerifierCapability.covers_content_digest` defaulted to "required" — a
pre-existing divergence from the spec. Surfaced by ad-tech-protocol-expert
review on PR #297 (foundation audit).

Operators who want body-integrity authentication end-to-end on every
request opt INTO `covers_content_digest="required"` explicitly, OR use
`required_for=frozenset({"create_media_buy", ...})` to promote
spend-committing operations selectively. The webhook-signing profile
(`adcp.signing.webhook_verifier`) hard-codes "required" regardless —
webhook bodies always carry signed digests.

Test renamed: `test_verifier_capability_defaults_to_either_digest`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes #299.

`adcp.webhooks.deliver()` (the legacy AdCP 3.x HMAC-SHA256 / Bearer auth
path) constructed an unpinned `httpx.AsyncClient(timeout=...)` and POSTed
to a buyer-controlled URL when no operator client was supplied. No SSRF
guard. A buyer-supplied URL pointing at 127.0.0.1 or AWS metadata would
deliver successfully — same security hole that PR #297 closed for
`WebhookSender`.

Surfaced by security-reviewer on PR #297 as L4 — explicitly deferred
from the prep PR scope. Now addressed.

The fix mirrors the WebhookSender pattern at webhook_sender.py:_send_bytes:

- When operator supplies a client, trust them completely (vetted egress
  proxy with mTLS, ASGI test transport, etc.).
- When sender owns the client, build a per-request
  AsyncIpPinnedTransport via build_async_ip_pinned_transport(url, ...).
  trust_env=False prevents HTTPS_PROXY env-var bypass.
  follow_redirects=False prevents rebinding-via-redirect.

New kwargs forwarded to the pinned-transport build:
- allow_private: bool = False — dev/CI escape hatch for internal endpoints
- allowed_ports: frozenset[int] | None = None — opt-in port-allowlist hardening

Three regression tests:
- test_deliver_owned_client_rejects_loopback_destination
- test_deliver_allow_private_dev_escape_hatch
- test_deliver_operator_supplied_client_skips_ssrf_guard

This is a behavior change for adopters on the legacy `deliver()` path
posting to private/internal endpoints (dev/test fixtures); they need to
add `allow_private=True` to preserve workflow. Production deployments
posting to real buyer URLs are unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ix, docstring

Code-reviewer + python-expert review of PR #303 surfaced four small
inline polish items, all addressed.

1. Module-top imports for `from adcp.signing import SSRFValidationError`
   and `from unittest.mock import patch` in test_webhooks_deliver.py —
   the rest of the file imports at module top; local imports were
   inconsistent with the file's existing style.

2. Renamed three new tests to drop the `test_deliver_*` prefix.
   The file is already scoped to `deliver()` tests (per its docstring);
   the prefix was redundant. Tests are now:
   - test_owned_client_rejects_loopback_destination
   - test_allow_private_dev_escape_hatch
   - test_operator_supplied_client_skips_ssrf_guard

3. Added a comment in test_allow_private_dev_escape_hatch flagging
   that the patch target depends on deliver()'s lazy in-function
   import. If the import moves to module-level on adcp.webhooks, the
   patch target must follow. Future-you note.

4. VerifierCapability docstring: "AdCP 4.0 expected to recommend"
   → "AdCP 4.0 recommends" (grounded in the spec rationale at
   get-adcp-capabilities-response.json).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley bokelley force-pushed the bokelley/signing-prep-followups branch from 7162676 to e0f6177 Compare April 29, 2026 23:46
@bokelley
Copy link
Copy Markdown
Contributor Author

Expert review pass complete — pushed e0f6177c

All 3 reviewers (security, code-review, python-expert) signed off. Two findings actioned + rebase resolved a duplicate.

Rebase: main had 4bd45d1c (PR #301) that had already closed #300 with an equivalent fix. My duplicate commit 999ed639 was dropped via cherry-pick of just the #298 + #299 commits onto current main. PR diff is now clean (no phantom 98-line deletion of the migration guide that landed via 01a10689).

Inline polish (this commit e0f6177c):

  1. Moved from adcp.signing import SSRFValidationError and from unittest.mock import patch to module top in tests/test_webhooks_deliver.py (local imports were inconsistent with the file's style)
  2. Renamed three new tests to drop redundant test_deliver_* prefix:
    • test_owned_client_rejects_loopback_destination
    • test_allow_private_dev_escape_hatch
    • test_operator_supplied_client_skips_ssrf_guard
  3. Added comment in test_allow_private_dev_escape_hatch flagging that the patch target depends on deliver()'s lazy in-function import (future-refactor note)
  4. VerifierCapability docstring tweak: "AdCP 4.0 expected to recommend" → "AdCP 4.0 recommends"

Reviewer-flagged items deferred / accepted:

  • Sign-then-validate ordering asymmetry between deliver() (HMAC before SSRF) and WebhookSender (SSRF before sign) — security: low (HMAC is over buyer body with buyer's own secret; nothing meaningfully leaks). Track for symmetry follow-up if it bothers.
  • HMAC-SHA256 SSRF coverage — only Bearer auth exercised in new tests. Both auth paths route through the same if client is None: branch so logically covered. Follow-up if asymmetry concerns.
  • Lazy import in deliver() could move to module-level (no cycle to avoid) — kept lazy for stylistic consistency with WebhookSender._send_bytes precedent. Test patch target documented.
  • #298 post-upgrade body-integrity loss for adopters using VerifierCapability default without required_for — release note will surface this; webhook-signing path is unaffected (hard-codes "required").

Final state:

  • 3 commits → 4 files changed, +183/-23
  • 2266 tests passing locally (3 new SSRF regression tests)
  • Pre-commit clean (black, ruff, mypy, bandit)

Ready to admin-merge once CI matrix lands.

@bokelley
Copy link
Copy Markdown
Contributor Author

Acknowledged — clean rebase, 3 reviewers signed off, 2266 tests green, pre-commit clean. Nothing actionable from my side; standing by for CI matrix.


Generated by Claude Code

@bokelley
Copy link
Copy Markdown
Contributor Author

Noted — standing by with you. Will re-engage if CI flags anything.


Generated by Claude Code

@bokelley
Copy link
Copy Markdown
Contributor Author

Acknowledged — monitoring alongside you. Will surface anything actionable if CI raises a flag.


Generated by Claude Code

@bokelley bokelley merged commit 072998a into main Apr 29, 2026
11 checks passed
bokelley added a commit that referenced this pull request Apr 30, 2026
…coverage + 4.1 migration notes

Three small follow-ups from the PR #303 second-pass review (security-reviewer
+ code-reviewer flagged each as low-priority but worth doing for symmetry):

1. validate-before-sign in webhooks.deliver() — mirror WebhookSender ordering.
   The pinned-transport build (which runs SSRF + port validation) now runs
   BEFORE body assembly + HMAC computation. A buyer-supplied 127.0.0.1 URL
   raises SSRFValidationError before get_adcp_signed_headers_for_webhook is
   called, so the HMAC-over-buyer-body never sits in process memory waiting
   for the rejection (anything that snapshots locals on exception cannot
   capture an HMAC that wasn't computed). Matches the
   WebhookSender._send_bytes pattern shipped in PR #297.

   Regression test test_owned_client_rejects_hostile_url_before_hmac_signing
   patches get_adcp_signed_headers_for_webhook with a MagicMock and asserts
   it's never called.

2. HMAC-SHA256 SSRF coverage — the existing
   test_owned_client_rejects_loopback_destination only exercised the Bearer
   auth path. Both auth paths route through the same SSRF guard but the
   tests should cover both for parity. Added
   test_owned_client_rejects_loopback_destination_hmac_path.

3. .gitignore — exclude .claude/scheduled_tasks.lock (Conductor harness
   runtime state).

Plus migration-guide section #4 covering the signing-prep behavior changes
landing in 4.1: SSRF guards on WebhookSender + deliver(), and the
covers_content_digest default flip from "required" to "either" (per AdCP
3.0 spec). Lists the opt-out kwargs adopters who relied on the prior
defaults need to add.

Tests: 2284 passing locally (2 new). Pre-commit clean.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant