security sweep

This commit is contained in:
2026-05-29 09:03:47 -04:00
parent 469c28fa64
commit 3b29de3234
60 changed files with 7148 additions and 413 deletions

View 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 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