The Mandrill webhook now checks its signature, without rejecting on it yet
Shipped 2026-09-10
/api/mandrill-webhook sits outside tokenValidate. That is correct — Mandrill
cannot present our JWT — but nothing ever replaced the check, so anyone who knows
the URL can post a batch of events and have them written into mandrill_logs
with a mandrill_id, status and member_id of their choosing. Those rows are
not inert: they feed the admin logs page support uses to answer “did this member
get their email”, and the daily health check that counts them to decide whether
email delivery is working at all. A forged batch can mask a real outage or invent
one.
Mandrill’s own answer to this is a signature on every call. X-Mandrill-Signature
is an HMAC-SHA1, keyed with the webhook’s key, taken over the registered URL
followed by each POST parameter sorted by key. Only Mandrill and we hold the key,
so a valid signature proves both where the call came from and that nobody edited
it on the way.
Why this ships without enforcing anything
The digest is computed over the URL exactly as registered in Mandrill’s
dashboard. If our copy differs by a single character — http for https, a
trailing slash, a host the proxy rewrites — every signature we compute is wrong
and every call would be rejected. No local test can tell us what was typed into
that dashboard, so switching enforcement on from a standing start is a guess.
The previous release logged only whether the header arrived, and that question is now settled: 6,291 calls over three days, every one carrying it, so it survives the DigitalOcean proxy intact. What that could not show is whether our key and our URL actually reproduce the digest. So this release computes the signature and compares it, and does nothing with the answer. A match logs a heartbeat; a mismatch logs at error with the URL we signed against, which is the field that names a trailing-slash bug outright.
Enforcement lives behind MANDRILL_WEBHOOK_ENFORCE_SIGNATURE and is off. With it
unset, every request is processed exactly as before, whatever the verdict.
Two details worth keeping in mind:
- The check runs in the route, not the service. The route answers
200before processing, because Mandrill retries anything that is not a prompt 2xx. A rejection decided after that acknowledgement is not a rejection at all — the batch has already been accepted — so the verdict has to be reached before the response goes out. - A missing key reads as
unconfigured, never asinvalid. If those two collapsed into one value, a key that went astray in Infisical would look exactly like a forged request, and enforcement would reject every genuine call in the same breath. The distinction is pinned by a test.
Before it does anything
MANDRILL_WEBHOOK_URL and MANDRILL_WEBHOOK_KEY have to reach the running API.
Copy the URL verbatim from Mandrill → Webhooks rather than retyping it; that is
the precise failure this staged rollout exists to catch. Until both are present
the verdict is unconfigured on every call.
Once they are in place, watch for mandrill_webhook.signature_invalid. Silence
across a day of traffic — roughly 2,000 events — means the URL and key are right
and the flag can be turned on in a follow-up.