highhigh confidenceCWE-345authenticationOpen
Stripe webhook is acted on before its signature is checked
src/webhooks/stripe.ts:31Exploit verified
Impact
Anyone who can reach the webhook URL can mark invoices paid or cancel subscriptions by posting a forged event. The handler runs the side effects before it checks the signature, and an exception in the check is swallowed.
AttackerAn unauthenticated caller on the internet
Crossesthe payment provider's signature
Getssubscription and payment state
Attack path
3 stepsEntry point: POST /webhooks/stripe, public by design.
- Step 1: src/webhooks/stripe.ts:26EntryThe raw body is parsed as JSON first.
25 const event = JSON.parse(req.body.toString()); - Step 2: src/webhooks/stripe.ts:28Side effects run on the parsed event.
27 await handlers[event.type]?.(event.data.object); - Step 3: src/webhooks/stripe.ts:31SinkOnly now is the signature checked, inside a try that logs and continues.
30 try { 31 stripe.webhooks.constructEvent(req.body, sig, secret); 32 } catch (err) { log.warn(err); }
Proof of concept
Exploit narrativePOST an invoice.paid event with any invoice id and no Stripe-Signature header. The invoice flips to paid and the response is 200.
Exploit verified via runtime replay: An unsigned invoice.paid body was accepted and applied.
Fix
Verify first with the raw body, reject on failure with 400, and only then dispatch the verified event object.
The fix must guarantee
- No handler runs for an event whose signature does not verify
It must not break
- Retries from the provider are still idempotent
Fix prompt for your coding agent
Paste into Claude Code, Cursor, or any coding agent. It carries the attack path, the guarantees, and the regression test.
In acme/api src/webhooks/stripe.ts, move stripe.webhooks.constructEvent above the dispatch, return 400 when it throws, and dispatch the event it returns instead of JSON.parse(req.body).