Forjamento de Webhook de Pagamento (Stripe)

Só leitura — não altera nada no alvo. Só em alvo seu ou autorizado por escrito.

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):

  1. Sempre usar stripe.webhooks.constructEvent() (verifica HMAC com STRIPE_WEBHOOK_SECRET).
  2. Idempotência: tabela processed_webhooks(event_id TEXT UNIQUE). INSERT falha se já existe.
  3. Nunca confiar em amount_total do webhook — buscar no Stripe API por session.retrieve().
  4. Raw body: await request.text(), NUNCA await request.json() (Stripe assina o raw).
  5. Stripe CLI para testar local: stripe listen --forward-to localhost:3000/api/webhooks/stripe.

PurpleLab // plataforma de treino ofensivo + defensivo // Fase 1 — portal dinamico