Webhook sem assinatura aceita "pagamento aprovado" de qualquer um
Confira a assinatura do webhook sobre o corpo cru, recuse horário velho ou id repetido e teste a rota com um POST forjado antes que alguém faça isso por você.
Quinta, 23h40. Um POST chegou em /webhooks/pagamento com {"pedido": 8812, "status": "aprovado"} e o sistema liberou três licenças de software. Não houve pagamento. A URL do webhook tinha aparecido num log de erro compartilhado num grupo, e a rota fazia exatamente o que o corpo mandava, sem perguntar quem estava mandando.
A resposta curta: uma rota de webhook só pode agir depois de conferir a assinatura calculada com o segredo compartilhado sobre o corpo exato que chegou, comparada em tempo constante. Além disso, recuse entregas com horário fora de uma janela curta quando o provedor assina o horário, e trate um id de entrega repetido como já processado. E, antes de confiar, mande você mesmo um POST sem assinatura e veja se algum dado muda.
Por que a URL secreta não protege nada
Muita integração começa com a ideia de que a URL é difícil de adivinhar. Só que URL não é segredo: ela vai para log de proxy, painel do provedor, print em chamado, histórico de terminal. Uma vez conhecida, qualquer pessoa consegue reproduzir a chamada com curl.
A assinatura resolve isso porque prova duas coisas ao mesmo tempo: quem enviou conhece o segredo, e o corpo não foi alterado no caminho. O provedor calcula um HMAC (geralmente HMAC-SHA256) do corpo com o segredo e manda o resultado num cabeçalho. Você recalcula do seu lado e compara. Sem o segredo, ninguém consegue produzir o valor certo para um corpo inventado.
O código de validação específico do Mercado Pago, do Stripe e do Asaas em PHP está no guia de webhook do Mercado Pago, Stripe e Asaas. Aqui o foco é o que faz a conferência falhar na prática, o detalhe do horário e o teste que mostra se a sua rota está realmente fechada.
O detalhe que quebra a conferência: o corpo cru
O erro mais comum não é esquecer a assinatura. É implementá-la, ver que nunca bate e desligar "temporariamente". A causa quase sempre é a mesma: o framework já leu o corpo e transformou em objeto JSON antes do seu código. Quando você faz JSON.stringify(req.body) para recalcular, a ordem das chaves, os espaços ou a representação de números e acentos podem sair diferentes do que o provedor assinou. Um byte diferente produz um HMAC completamente diferente.
A regra é: calcule a assinatura sobre os bytes que chegaram, e só depois faça o parse.
Conferindo a assinatura do GitHub em Node.js
O GitHub manda o cabeçalho X-Hub-Signature-256 no formato sha256= seguido do HMAC-SHA256 em hexadecimal, e um id único por entrega em X-GitHub-Delivery. O exemplo usa Express com express.raw só nessa rota, para receber o corpo como Buffer:
const crypto = require('crypto');
const express = require('express');
const app = express();
const SEGREDO = process.env.WEBHOOK_SECRET;
function assinaturaValida(corpo, cabecalho) {
if (typeof cabecalho !== 'string' || !cabecalho.startsWith('sha256=')) return false;
const esperado = 'sha256=' + crypto.createHmac('sha256', SEGREDO).update(corpo).digest('hex');
const a = Buffer.from(esperado);
const b = Buffer.from(cabecalho);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post('/webhooks/github', express.raw({ type: 'application/json' }), async (req, res) => {
if (!assinaturaValida(req.body, req.get('X-Hub-Signature-256'))) {
return res.status(401).end();
}
const entrega = req.get('X-GitHub-Delivery');
const novo = await registrarEntrega(entrega);
if (!novo) return res.status(200).end();
const evento = JSON.parse(req.body.toString('utf8'));
await fila.publicar({ entrega, tipo: req.get('X-GitHub-Event'), evento });
res.status(202).end();
});
Três detalhes importam. A comparação usa crypto.timingSafeEqual, que exige buffers do mesmo tamanho, por isso o teste de tamanho vem antes. A rota responde 401 sem dizer o motivo. E o trabalho pesado vai para uma fila: o provedor espera resposta rápida e, se demorar, considera a entrega falha e pode tentar de novo, o que duplica o efeito se você não tiver deduplicação.
registrarEntrega pode ser um INSERT numa tabela com chave única no id da entrega, devolvendo falso quando a linha já existe. A lógica é a mesma do guia de idempotência no pagamento: o efeito acontece uma vez, por mais que a mensagem chegue duas.
Horário assinado contra replay
Assinatura sozinha não impede que alguém capture uma entrega legítima e reenvie a mesma, byte por byte, uma semana depois. A assinatura continua válida, porque o corpo é o mesmo.
Alguns provedores resolvem isso incluindo um horário dentro do que é assinado. O Stripe, por exemplo, manda um cabeçalho com o horário e a assinatura juntos, e o valor assinado combina o horário com o corpo. Quando o provedor faz isso, recuse entregas cujo horário esteja longe demais do seu relógio, com uma janela de poucos minutos, e mantenha o servidor sincronizado por NTP, senão você recusa entregas legítimas (o post sobre relógio torto no token mostra como conferir).
O GitHub não assina um horário. Nesse caso, a proteção contra replay é o id de entrega: guarde os ids processados por um período maior que a janela de reenvio do provedor e recuse repetidos. Se você é quem envia webhooks para clientes, prefira um esquema que assine id, horário e corpo juntos.
Como testar se a rota está fechada
- Mande um POST sem cabeçalho de assinatura e confirme 401 e nenhuma linha nova no banco.
- Mande um POST com assinatura inventada, como
sha256=00, e confirme o mesmo resultado. - Gere uma assinatura válida com o segredo de teste e altere um caractere do corpo depois. Tem de falhar.
- Reenvie uma entrega válida com o mesmo id. A segunda deve responder 200 sem repetir o efeito.
- Use o botão de reenvio do painel do provedor e confirme que a entrega legítima passa.
CORPO='{"action":"completed"}'
SIG=$(printf '%s' "$CORPO" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | sed 's/^.* //')
# sem assinatura: espera 401
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/webhooks/github \
-H "Content-Type: application/json" -d "$CORPO"
# assinatura correta: espera 202 na primeira vez
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/webhooks/github \
-H "Content-Type: application/json" -H "X-GitHub-Delivery: teste-1" \
-H "X-Hub-Signature-256: sha256=$SIG" -d "$CORPO"
Rode o segundo comando de novo: com o mesmo id teste-1, a resposta deve ser 200 e nada deve ser processado outra vez.
Erros comuns
- Recalcular sobre
JSON.stringify(req.body): a assinatura nunca bate, alguém desliga a conferência "por enquanto" e ela nunca mais volta. - Comparar com
===: a comparação termina no primeiro byte diferente, o que em tese permite medir o tempo e descobrir a assinatura aos poucos. - Processar antes de conferir: qualquer log, gravação ou chamada externa feita antes da conferência já é efeito de um pedido não autenticado.
- Fazer o trabalho pesado dentro da requisição: o provedor estoura o tempo, reenvia e o evento é processado duas vezes.
- Segredo no código ou no repositório: quem lê o repositório consegue forjar entregas, e trocar o segredo vira emergência.
- Ignorar o id de entrega: uma entrega capturada pode ser repetida indefinidamente com assinatura válida.
Checklist
- POST sem assinatura responde 401 e não altera dado nenhum.
- A assinatura é calculada sobre o corpo cru, antes do parse.
- A comparação usa função de tempo constante.
- Ids de entrega processados ficam registrados com chave única.
- Quando o provedor assina horário, entregas fora da janela são recusadas.
- A rota responde em poucos segundos e manda o trabalho para fila.
- O segredo vem de variável de ambiente ou cofre e tem um procedimento de troca.
Pegue a rota de webhook mais importante do seu sistema e mande agora um POST sem o cabeçalho de assinatura: algum dado muda no banco?
Referência: https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries