Repository navigation
feat(providers): signed Mailchimp campaign webhooks with replay protection - #182
Open
TheAggressive wants to merge 2 commits into
Open
TheAggressive wants to merge 2 commits into
TheAggressive wants to merge 2 commits into
Conversation
…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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
signing_enabled: trueand a 43-charactersigning_secret, shown only once;GET(User-AgentMailChimp.com WebHook Validator (webhook-v1));DELETEreturns204.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 intests/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.GETanswers200for Mailchimp's URL check.POSTis capped at 64 KB and limited to 120 requests per minute per client address.401,400,413,429,503).200 {"received": true}.Webhook_Intake). Acampaignevent for a scheduled, sending, or unknown campaign queues onereconcile_campaign_hintjob, which reads Mailchimp before anything changes.webhook_receipts(receipt_key char(64),expires_at). Nothing else from a delivery is stored or logged.Webhook_Registrar, Settings → Providers → Campaign webhooks): Turn on, Add missing audiences, Rotate secrets, Turn off.campaignbridge_manage_connectionsand a nonce.webhookscapability.campaignbridge_webhook_urlfilter for sites whose public address differs from their REST URL (proxies, tunnels).401troubleshooting, schema-10 rollback), and roadmap state.Evidence (#172 required evidence)
Provider_Webhook_Security_Test):Provider_Webhook_Test, using the real reconciler and scripted Mailchimp):scheduledand Mailchimp isn't read during the request;sent, with an automatic audit event and no send action;404as removed; disconnect removes webhooks.alertnotice 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.pnpm qa:fastpassed andpnpm buildsucceeded; 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_userwith 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, andCampaign_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