Files
Kordant/piolium/findings/p8-007-webhook-replay/draft.md
2026-05-29 09:03:47 -04:00

3.1 KiB
Raw Blame History

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.ts lines 1821 (entry point)
  • web/src/server/services/billing.service.ts lines 142223 (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.updated to change user tier (e.g., downgrade premium users)
  • Replay invoice.paid to re-activate canceled subscriptions
  • Replay customer.subscription.deleted to cancel active subscriptions (DoS)
  • checkout.session.completed is partially protected by onConflictDoNothing(), but replayed events with different stripeId values 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

  1. Attacker obtains STRIPE_WEBHOOK_SECRET (via log exposure or other means)
  2. Attacker sends a forged POST to /api/stripe/webhook with valid Stripe signature
  3. Event type: customer.subscription.updated with attacker-controlled tier/status
  4. handleWebhookEvent() processes the event without checking event ID
  5. Subscription state is updated to attacker-controlled values

Defense Search Results

  • Stripe signature verification (constructEvent()) prevents forgery without the secret
  • onConflictDoNothing() provides partial protection for checkout.session.completed
  • No event ID deduplication table or check
  • No idempotency key in the webhook handler
  • updateSubscriptionInDB does not check for duplicate processing