60 lines
3.1 KiB
Markdown
60 lines
3.1 KiB
Markdown
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
|