Skip to content

feat(providers): signed Mailchimp campaign webhooks with replay protection - #182

Open
TheAggressive wants to merge 2 commits into
masterfrom
feat/mailchimp-webhooks
Open

TheAggressive wants to merge 2 commits into
masterfrom
feat/mailchimp-webhooks

Conversation

@TheAggressive

@TheAggressive TheAggressive commented Oct 17, 2026 •

Copy link
Copy Markdown
Owner

Part of #172 (M5, #66). This leaves #172 open until one real campaign-sent delivery has been verified against the live account (see Not yet verified).

Correction to #172

The issue says "Mailchimp does not sign webhooks". That is out of date. A live probe on 2026-10-17 created a webhook through the API on a real audience, then deleted it. It showed:

  • the create response returns signing_enabled: true and a 43-character signing_secret, shown only once;
  • before answering the create, Mailchimp checks the URL with GET (User-Agent MailChimp.com WebHook Validator (webhook-v1));
  • DELETE returns 204.

Mailchimp signs each delivery with X-Mailchimp-Signature: t={unix seconds},v1={hex}, an HMAC-SHA256 of {t}.{raw body}, and asks receivers to refuse anything older than five minutes. Deliveries are therefore authenticated by signature against encrypted per-audience secrets, not a secret in the URL. The probe exchange is recorded in tests/Fixtures/Mailchimp/webhook-registration.json.

What this adds

  • POST/GET /campaignbridge/v1/webhooks/mailchimp (Provider_Webhook_Routes). The route is public because providers can't log in; the callback authenticates every delivery before reading it.
    • GET answers 200 for Mailchimp's URL check.
    • POST is capped at 64 KB and limited to 120 requests per minute per client address.
    • The signature is checked against the raw bytes before parsing.
    • Every refusal returns the same message (401, 400, 413, 429, 503).
    • Accepted, duplicate, and ignored deliveries all answer 200 {"received": true}.
  • A delivery is only a hint (Webhook_Intake). A campaign event for a scheduled, sending, or unknown campaign queues one reconcile_campaign_hint job, which reads Mailchimp before anything changes.
    • It runs even before a scheduled send time, because the campaign may have been sent early from the Mailchimp app.
    • It never stops or replaces the regular watch.
    • Repeated hints collapse into one queued run.
    • Subscriber events and campaigns CampaignBridge doesn't know are ignored.
  • Replay protection. A replay inside the five-minute window is recognized by a one-way receipt; a later one fails the signature check.
    • Schema 10 adds webhook_receipts (receipt_key char(64), expires_at). Nothing else from a delivery is stored or logged.
  • Registration as its own operator action (Webhook_Registrar, Settings → Providers → Campaign webhooks): Turn on, Add missing audiences, Rotate secrets, Turn off.
    • Requires campaignbridge_manage_connections and a nonce.
    • Creates one webhook per audience, for campaign events only, so subscriber personal data is never sent to the site.
    • Handles each audience on its own and saves it immediately, so repeating the action finishes an interrupted one.
    • Before creating, removes any webhook already pointing at this site (one left by an unconfirmed create, whose secret was never stored).
    • Removes a webhook again if it comes back unsigned.
    • Secrets are encrypted at rest.
    • Disconnect removes the webhooks while the key still works.
  • Retry policy: webhook creation is create-once and never replayed; lookups and removal are repeatable. Mailchimp advertises a new webhooks capability.
  • campaignbridge_webhook_url filter for sites whose public address differs from their REST URL (proxies, tunnels).
  • Docs updated: API, architecture, campaign workflows, threat model, runbook (operation, 401 troubleshooting, schema-10 rollback), and roadmap state.

Evidence (#172 required evidence)

  • Security (Provider_Webhook_Security_Test):
    • wrong, missing, garbage, and body-altered signatures, stale timestamps, no stored webhooks, oversized bodies, and signed-but-malformed bodies are each refused with the same message, and none of them records a receipt or a job;
    • an in-window replay is answered exactly like the original and stores only a 64-character hash and its expiry;
    • the route is registered with the signature guard for known providers only;
    • the per-client rate limit applies;
    • only connection managers can change webhooks, and no provider call is made otherwise.
  • Integration (Provider_Webhook_Test, using the real reconciler and scripted Mailchimp):
    • the webhook body changes nothing: the campaign stays scheduled and Mailchimp isn't read during the request;
    • the hint job then reads once and settles a campaign sent early to sent, with an automatic audit event and no send action;
    • a hint Mailchimp doesn't confirm changes nothing and leaves the watch running;
    • a duplicate, a replay, and a retry for one campaign produce one queued check and one read;
    • registration stores only encrypted secrets, so no plaintext appears in options;
    • a repeat registration is a no-op; orphans are removed and partial failures are kept for retry; rotation invalidates the old secret; disabling treats a 404 as removed; disconnect removes webhooks.
  • Unit: the signature parser and freshness edges, payload parsing; the gateway against the recorded exchange (only campaign events, never replayed, unsigned webhooks removed again, refused URL explained without provider detail).
  • Admin screen: the section renders state, a labelled webhook address, confirmations on Rotate and Turn off, and an alert notice with a plain reason. No secret or ciphertext reaches the page. It's hidden from people who can't manage connections, and a forged nonce is refused.
  • Gates: pnpm qa:fast passed and pnpm build succeeded; all PHPUnit suites pass (1796 tests, 10,583 assertions).

Test hardening

The minimum-WordPress CI job failed Provider_Discovery_Routes_Test::test_refresh_is_rate_limited_per_user with 200 where 429 was expected. Request limits count per fixed 60-second window, so a burst that crosses a window boundary starts counting again and is never refused. The test predates this PR, and the new webhook rate-limit test had the same flaw. Test_Case::start_in_one_rate_window() now waits out a window that is about to end; it's used by that test, the webhook test, and Campaign_Routes_Test's expensive-route limit test.

Not yet verified

Rollback

Turn webhooks off, reinstall a schema-9 release, then run wp option update campaignbridge_database_schema 9. Nothing else changed in existing data.

🤖 Generated with Claude Code

…ction

Mailchimp signs every delivery of a webhook created through the API
(HMAC-SHA256 of "{t}.{raw body}", five-minute freshness), so deliveries are
authenticated by signature against encrypted per-audience secrets rather
than a secret in the URL. Confirmed against a live account on 2026-10-17:
the create response returns signing_enabled and a one-time signing_secret,
and Mailchimp validates the URL with a GET first.

- Public /webhooks/mailchimp route: GET answers the URL check; POST is
  size-capped, rate-limited, signature-checked before parsing, and refuses
  with one undifferentiated error.
- A delivery is only a hint: a campaign event for a watched campaign queues
  one reconcile_campaign_hint job that reads Mailchimp before anything
  changes, even before a scheduled send time. Duplicates collapse into one
  queued run; replays inside the window are recognized by a one-way receipt
  (schema 10), older ones fail the signature.
- Webhook_Registrar keeps one campaign-events-only webhook per audience as
  its own operator action (turn on, add missing, rotate, turn off), removing
  any orphan from an unconfirmed create first. Disconnect removes webhooks
  while the key still works.
- Webhook creation is create-once in the retry policy; lookups and removal
  are repeatable.

Part of #172.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added area:frontend Frontend source or Node tooling area:php PHP or Composer code area:providers Provider integrations or delivery boundaries area:tests Automated tests or test configuration risk:high Security or release sensitive; never auto-merged type:feature New capability needs-attention Automation stopped; maintainer action required labels Oct 17, 2026
Request limits count per fixed 60-second window, so a burst that crossed a
window boundary started counting again and was never refused. The discovery
refresh test failed this way on the minimum-WordPress CI job. Tests that
expect a burst to be refused now wait out a window that is about to end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:frontend Frontend source or Node tooling area:php PHP or Composer code area:providers Provider integrations or delivery boundaries area:tests Automated tests or test configuration needs-attention Automation stopped; maintainer action required risk:high Security or release sensitive; never auto-merged type:feature New capability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant