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 18–21 (entry point) - `web/src/server/services/billing.service.ts` lines 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.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 ```typescript // 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