Um webhook de pagamento chega ao seu servidor afirmando que um pedido foi pago. Você confia nisso? Se você creditar o pedido com base apenas na chamada HTTP — sem verificar uma assinatura criptográfica — você acabou de dar aos atacantes uma maneira gratuita de marcar qualquer pedido como pago.
O Que Pode Dar Errado Sem Verificação
Um endpoint de webhook desprotegido é trivialmente explorável. Um atacante só precisa saber sua URL de callback — que muitas vezes pode ser descoberta na aba de rede de um navegador, em um repositório público do GitHub ou em um checkout de teste — e eles podem POSTar uma carga útil manipulada:
POST /webhook HTTP/1.1
Content-Type: application/json
{"order_id": "1337", "status": "paid", "amount": "99.99"}Se seu manipulador lê $_POST['status'] === 'paid' e credita o pedido, o atacante acabou de comprar $99.99 em mercadorias de graça. Este não é um ataque teórico; acontece regularmente contra integrações de pagamento desprotegidas.
Como Funciona a Assinatura HMAC-SHA256
HMAC (Código de Autenticação de Mensagem Baseado em Hash) com SHA-256 resolve isso tornando a carga útil inautenticável sem a chave secreta. O processo é:
- O gateway de pagamento conhece seu api_secret — um segredo compartilhado apenas entre você e o gateway.
- Antes de enviar o webhook, ele calcula:
HMAC-SHA256(timestamp + "." + body, api_secret) - Ele inclui esta assinatura e o timestamp nos cabeçalhos HTTP.
- Seu servidor recalcula o mesmo HMAC e compara. Se eles coincidirem, a carga útil é autêntica.
Um atacante que não conhece seu api_secret não pode forjar uma assinatura válida — SHA-256 é uma função unidirecional. O timestamp previne ataques de repetição: mesmo um webhook interceptado válido se torna inútil após 5 minutos.
Implementando Verificação: PHP
function verifyPayoraWebhook(string $secret): array {
$ts = $_SERVER['HTTP_X_PAYORA_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_PAYORA_SIGNATURE'] ?? '';
$body = file_get_contents('php://input');
// 1. rejeitar timestamps antigos (proteção contra repetição)
if (!ctype_digit($ts) || abs(time() - (int)$ts) > 300) {
http_response_code(400); exit('timestamp antigo');
}
// 2. recomputar e comparar — hash_equals() previne ataques de tempo
$expected = hash_hmac('sha256', $ts . '.' . $body, $secret);
if (!hash_equals($expected, $sig)) {
http_response_code(401); exit('assinatura inválida');
}
return json_decode($body, true);
}
$event = verifyPayoraWebhook('sk_your_secret');
if ($event['status'] === 'paid') {
creditOrder($event['order_id']);
}Implementando Verificação: Node.js
import crypto from 'node:crypto';
function verifyPayoraWebhook(req, rawBody, secret) {
const ts = req.headers['x-payora-timestamp'] ?? '';
const sig = req.headers['x-payora-signature'] ?? '';
if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
return null; // antigo
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${ts}.${rawBody}`)
.digest('hex');
// timingSafeEqual previne ataques de canal lateral de tempo
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))
? JSON.parse(rawBody)
: null;
}Erros Comuns Que Quebram a Verificação
1. Lendo um corpo analisado em vez dos bytes brutos. O middleware json() do Express re-serializa o JSON, o que pode reordenar chaves ou mudar espaços em branco. Sempre armazene em buffer o corpo da requisição bruta antes de analisar, e use isso para o HMAC.
2. Usando === em vez de uma comparação segura em tempo. Uma comparação de string ingênua vaza informações de tempo que podem ser exploradas para adivinhar a assinatura byte a byte. Use hash_equals() em PHP ou crypto.timingSafeEqual() em Node.
3. Pulando a verificação do timestamp. Um webhook válido, mas antigo, capturado por um observador de rede poderia ser reproduzido. A janela de 5 minutos (±300 segundos) elimina esse risco enquanto lida com um desvio de relógio razoável.
4. Registrando o corpo bruto antes da verificação. Se seu pipeline de registro for comprometido, as cargas registradas poderiam ser reproduzidas. Verifique primeiro, registre depois.
Além da Assinatura: Idempotência
A verificação prova que o webhook é autêntico; a idempotência garante que você não credite um pedido duas vezes. Redes reenviam webhooks em respostas não-200, então seu manipulador pode receber o mesmo evento várias vezes. A solução é registrar um webhook_id em seu banco de dados e pular o processamento se você já o viu antes:
// pseudo-código
$id = $event['webhook_id'];
if (DB::exists('processed_webhooks', $id)) return respond(200);
DB::insert('processed_webhooks', $id);
creditOrder($event['order_id']);Juntas, a verificação de assinatura e a idempotência oferecem uma integração de pagamento que é segura e resiliente. Veja o guia completo de integração para a referência completa de webhook da Payora.
Comments