Forjamento de Webhook de Pagamento (Stripe)
O vetor
Vetor real: quando você integrar Stripe, o atacante envia um POST falso para seu endpoint /api/webhooks/stripe imitando checkout.session.completed. Se seu sistema não verifica a assinatura Stripe-Signature, ele libera acesso/produto sem pagamento. É uma das fraudes mais comuns em e-commerce — Stripe registra milhões de tentativas.
Pré-requisito: este exercício só roda após você implementar o endpoint de webhook. Hoje ele não existe (404), então o teste é preventivo — documentar o que NÃO fazer quando implementar.
Os passos
Passo 1 — Verificar se o endpoint de webhook existe:
BASE="https://seu-app.exemplo.workers.dev"
curl -s -o /dev/null -X POST "$BASE/api/webhooks/stripe" \
-H "Content-Type: application/json" \
-d '{"type":"checkout.session.completed","data":{"object":{"id":"cs_test_fake"}}}' \
-w "Webhook endpoint: HTTP %{http_code}\n"
O que esperar
Resultado esperado hoje: HTTP 404 (endpoint não existe ainda). ✅ Bom — nada para forjar agora.
Passo 2 — Quando implementar, ESTE é o teste a rodar (sem verificação de assinatura):
# Simular webhook forjado SEM assinatura válida:
curl -s -X POST "$BASE/api/webhooks/stripe" \
-H "Content-Type: application/json" \
-H "Stripe-Signature: t=9999999999,v1=fake_signature" \
-d '{
"type": "checkout.session.completed",
"data": {
"object": {
"id": "cs_fake_123",
"client_reference_id": "user_attacker_001",
"amount_total": 4999,
"payment_status": "paid"
}
}
}' | python -m json.tool
Resultado que NÃO DESEJAMOS (vulnerável): HTTP 200 + {"received": true} sem verificar a assinatura. Se acontecer, o atacante "comprou" sem pagar.
Resultado que DESEJAMOS (seguro): HTTP 400 + {"error": "Invalid signature"}.
Passo 3 — O código correto que você DEVE implementar:
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const sig = request.headers.get('stripe-signature');
const body = await request.text();
let event: Stripe.Event;
try {
// constructEvent verifies the signature cryptographically
event = stripe.webhooks.constructEvent(
body,
sig!,
process.env.STRIPE_WEBHOOK_SECRET! // whipping secret from Stripe dashboard
);
} catch (err) {
return new Response('Invalid signature', { status: 400 });
}
// Only NOW process the event
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session;
// Fulfill the purchase...
}
return new Response('received', { status: 200 });
}
Passo 4 — Idempotência (proteção contra replay):
# Atacante captura um webhook VÁLIDO (via MITM ou log leak) e reenvia:
curl -s -X POST "$BASE/api/webhooks/stripe" \
-H "Content-Type: application/json" \
-H "Stripe-Signature: <assinatura_capturada>" \
-d '<payload_capturado>'
Resultado desejado: HTTP 200 mas event.id já foi processado → não duplica. Implementar tabela processed_events(event_id UNIQUE).
Como corrigir
Correção necessária (P2 — antes de lançar pagamentos):
- Sempre usar
stripe.webhooks.constructEvent()(verifica HMAC comSTRIPE_WEBHOOK_SECRET). - Idempotência: tabela
processed_webhooks(event_id TEXT UNIQUE). INSERT falha se já existe. - Nunca confiar em
amount_totaldo webhook — buscar no Stripe API porsession.retrieve(). - Raw body:
await request.text(), NUNCAawait request.json()(Stripe assina o raw). - Stripe CLI para testar local:
stripe listen --forward-to localhost:3000/api/webhooks/stripe.