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

60 lines
3.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
```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