3.1 KiB
Phase: 8 Sequence: 007 Slug: webhook-replay Verdict: VALID Rationale: Stripe webhook handler has no event ID deduplication for most event types; replayed events for invoice.paid, invoice.payment_failed, customer.subscription.updated, and customer.subscription.deleted will re-execute their handlers Severity-Original: medium Severity: medium PoC-Status: pending Pre-FP-Flag: none Debate: piolium/attack-surface/balanced-chamber-summary.md
Summary
The Stripe webhook handler at /api/stripe/webhook has no event ID deduplication. While checkout.session.completed uses onConflictDoNothing() on stripeId (providing partial protection), other event types (invoice.paid, invoice.payment_failed, customer.subscription.updated, customer.subscription.deleted) have no idempotency checks. An attacker who obtains STRIPE_WEBHOOK_SECRET can forge or replay events to manipulate subscription state.
Location
web/src/routes/api/stripe/webhook.tslines 18–21 (entry point)web/src/server/services/billing.service.tslines 142–223 (handler)
Attacker Control
An attacker with STRIPE_WEBHOOK_SECRET (or via log exposure) can forge or replay webhook events. The attacker can replay customer.subscription.updated to change user tier, invoice.paid to re-activate canceled subscriptions, or customer.subscription.deleted to cancel active subscriptions.
Trust Boundary Crossed
External API boundary (Stripe webhook) → Payment processing boundary. Replay of webhook events can manipulate subscription state without Stripe's knowledge.
Impact
- Replay
customer.subscription.updatedto change user tier (e.g., downgrade premium users) - Replay
invoice.paidto re-activate canceled subscriptions - Replay
customer.subscription.deletedto cancel active subscriptions (DoS) checkout.session.completedis partially protected byonConflictDoNothing(), but replayed events with differentstripeIdvalues could still succeed
Evidence
// checkout.session.completed — partial protection
await db.insert(subscriptions).values({...}).onConflictDoNothing();
// Other event types — NO idempotency check
case "invoice.paid": {
await updateSubscriptionInDB(invoice.subscription as string, { status: "active" });
break;
}
case "customer.subscription.updated": {
await updateSubscriptionInDB(stripeSub.id, { tier, status, ... });
break;
}
Reproduction Steps
- Attacker obtains
STRIPE_WEBHOOK_SECRET(via log exposure or other means) - Attacker sends a forged POST to
/api/stripe/webhookwith valid Stripe signature - Event type:
customer.subscription.updatedwith attacker-controlled tier/status handleWebhookEvent()processes the event without checking event ID- Subscription state is updated to attacker-controlled values
Defense Search Results
- Stripe signature verification (
constructEvent()) prevents forgery without the secret onConflictDoNothing()provides partial protection forcheckout.session.completed- No event ID deduplication table or check
- No idempotency key in the webhook handler
updateSubscriptionInDBdoes not check for duplicate processing