security sweep
This commit is contained in:
59
piolium/findings/p8-007-webhook-replay/draft.md
Normal file
59
piolium/findings/p8-007-webhook-replay/draft.md
Normal file
@@ -0,0 +1,59 @@
|
||||
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
|
||||
Reference in New Issue
Block a user