Segurança em Webhooks com Assinaturas HMAC-SHA256 e Janelas de Tempo
A Ilusão da URL Secreta: O Risco dos Webhooks Abertos
Sempre que a tua equipa integra um serviço externo de faturação, pagamento por cartão (como o Stripe ou Easypay) ou notificações de entregas, precisas de criar um endpoint público para receber alertas em tempo real:
POST https://api.tuaempresa.pt/api/v1/webhooks/pagamento-concluido
Para que o servidor externo consiga entregar as mensagens, este endereço tem de estar acessível na internet aberta sem exigir os cookies de login habituais do teu utilizador.
E é aqui que muitos programadores cometem um erro grave: assumem que, como o endereço do webhook não está publicado na página inicial ou tem um nome estranho, ninguém o vai descobrir.
Na realidade da cibersegurança, os URLs aparecem facilmente em registos de erro, em proxies intermediários e em scanners automáticos de vulnerabilidades. Se alguém descobrir o teu endereço de webhook, pode abrir a consola e enviar um pedido simulado:
# Um atacante envia dados falsos para o teu webhook:
curl -X POST https://api.tuaempresa.pt/api/v1/webhooks/pagamento-concluido \
-H "Content-Type: application/json" \
-d '{"evento":"pagamento_aprovado","encomenda_id":1045,"valor":45000,"cliente":"invasor@teste.pt"}'
Se não tiveres uma validação criptográfica ativa, o teu código em Node.js ou PHP lê o JSON com inocência, atualiza a base de dados e marca uma encomenda de 450 euros como “Paga” sem que tenha entrado um único cêntimo na tua conta bancária!
O PERIGO DE UM WEBHOOK SEM VALIDAÇÃO CRIPTOGRÁFICA
- Computador do Atacante (curl)
- A Tua Rota de Webhook
- POST /api/webhooks/pagamento
- Dados: {“estado”:“PAGO”, “encomenda”:1045} — (Lê o JSON sem verificar) (Atualiza no PostgreSQL!)
- 200 OK (Produto Expedido para o Atacante!)
- A tua empresa envia mercadoria real sem nunca ter recebido o dinheiro!
Para protegeres as tuas web apps de acordo com os padrões da OWASP, todas as rotas de webhook têm de validar obrigatoriamente a assinatura criptográfica HMAC-SHA256 conjugada com janelas de tolerância de tempo.
A Proteção Criptográfica: Como Funciona o HMAC-SHA256
O padrão da indústria adotado por empresas como a Stripe, o GitHub e o Shopify baseia-se no HMAC (Hash-based Message Authentication Code) com a cifra SHA-256.
O serviço externo e a tua aplicação partilham um segredo prévio (whsec_...). Quando acontece um evento real:
- O emissor recolhe a data e hora atual em segundos Unix (
t=1730000000). - Junta o carimbo de data aos bytes do corpo do pedido:
t=1730000000.{"evento":"encomenda_paga"}. - Calcula o hash HMAC-SHA256 com a chave secreta partilhada.
- Envia o resultado no cabeçalho HTTP:
Stripe-Signature: t=1730000000,v1=9f8e7d6c5b4a3928170e1f2a3b4c5d6e...
PROCESSO DE ASSINATURA E VALIDAÇÃO HMAC-SHA256
- QUEM ENVIA (Ex: Gateway de Pagamentos): [Carimbo Temporal (t)] + “.” + [Bytes do Corpo do Pedido]
- HMAC-SHA256(dados, CHAVE_SECRETA_DO_WEBHOOK) → Assinatura: “a9f8e…”
- Envia Pedido HTTP com Cabeçalho: X-Assinatura: t=173000,v1=a9f8e…
- QUEM RECEBE (O Teu Servidor): 1. Extrai o carimbo ‘t’ e a assinatura ‘v1’ do cabeçalho.
- Recalcula: HMAC-SHA256(t + “.” + BYTES_EM_BRUTO, CHAVE_SECRETA).
- Comparação de Tempo Constante: A assinatura bate certo?
- SIM: Processa o pagamento com certeza matemática absoluta!
- NÃO: Rejeita de imediato com HTTP 400! Falsa notificação descartada!
Como o invasor não conhece a tua chave secreta, é matematicamente impossível forjar uma assinatura válida.
A Rasteira do JSON: Porque Tens de Validar os Bytes em Bruto
O erro mais comum cometido pelos programadores nesta validação é tentar verificar o objeto JSON depois de este ter sido convertido pelo servidor.
Repara neste engano clássico:
// O erro típico em Express ou Fastify:
app.use(express.json()); // Converte os bytes num objeto de JavaScript!
app.post('/webhook', (req, res) => {
// Voltar a transformar o objeto em texto NÃO recria os bytes originais!
const textoReconstruido = JSON.stringify(req.body);
const assinatura = calcularHmac(textoReconstruido); // A ASSINATURA FALHA SEMPRE!
});
Isto acontece porque:
- O JavaScript pode mudar a ordem dos campos:
{"a":1,"b":2}vira{"b":2,"a":1}. - Os espaços e quebras de linha originais foram eliminados na leitura.
- Números decimais longos podem sofrer pequenos arredondamentos.
Para o cálculo bater certo, tens de ler a sequência de bytes original e inalterada tal como viajou pelo cabo de rede.
Implementação Prática em Node.js com Fastify
Vê como podes estruturar esta validação de forma segura em Node.js com o Fastify e o módulo nativo de criptografia:
// src/rotas/receberWebhook.ts
import { FastifyPluginAsync } from 'fastify';
import crypto from 'node:crypto';
const SEGREDO_WEBHOOK = process.env.STRIPE_WEBHOOK_SECRET!;
const JANELA_MAXIMA_SEGUNDOS = 300; // Tolerância de 5 minutos
export const receberWebhook: FastifyPluginAsync = async (app) => {
// Garante que o Fastify preserva os bytes em bruto como Buffer
app.addContentTypeParser(
'application/json',
{ parseAs: 'buffer' },
(req, body, done) => {
done(null, body);
}
);
app.post('/api/v1/webhooks/stripe', async (req, reply) => {
const bufferBruto = req.body as Buffer;
const cabecalhoAssinatura = req.headers['stripe-signature'] as string;
if (!cabecalhoAssinatura || !bufferBruto) {
return reply.status(400).send({ erro: 'Faltam dados ou assinatura.' });
}
// Passo 1: Extrai o carimbo temporal e a assinatura (formato: t=timestamp,v1=hash)
const elementos = cabecalhoAssinatura.split(',');
const itemTempo = elementos.find((el) => el.startsWith('t='));
const itemAssinatura = elementos.find((el) => el.startsWith('v1='));
if (!itemTempo || !itemAssinatura) {
return reply.status(400).send({ erro: 'Cabeçalho de assinatura inválido.' });
}
const timestamp = parseInt(itemTempo.split('=')[1], 10);
const assinaturaRecebida = itemAssinatura.split('=')[1];
// Passo 2: Previne Ataques de Repetição (Verifica a Janela Temporal)
const agoraEmSegundos = Math.floor(Date.now() / 1000);
if (Math.abs(agoraEmSegundos - timestamp) > JANELA_MAXIMA_SEGUNDOS) {
return reply.status(400).send({ erro: 'Carimbo de data fora do prazo permitido.' });
}
// Passo 3: Recalcula o HMAC sobre: timestamp + "." + bufferBruto
const hmac = crypto.createHmac('sha256', SEGREDO_WEBHOOK);
hmac.update(`${timestamp}.`);
hmac.update(bufferBruto);
const assinaturaEsperada = hmac.digest('hex');
// Passo 4: Comparação em Tempo Constante (Evita Timing Attacks)
const bufferRecebido = Buffer.from(assinaturaRecebida, 'hex');
const bufferEsperado = Buffer.from(assinaturaEsperada, 'hex');
if (
bufferRecebido.length !== bufferEsperado.length ||
!crypto.timingSafeEqual(bufferRecebido, bufferEsperado)
) {
return reply.status(400).send({ erro: 'Assinatura criptográfica não coincide.' });
}
// Passo 5: Converte o JSON em segurança após a aprovação criptográfica
const eventoVerificado = JSON.parse(bufferBruto.toString('utf-8'));
console.log(`[Webhook Confirmado] Evento: ${eventoVerificado.type}`);
// Devolve 200 OK de imediato para não causar timeout no fornecedor
return reply.status(200).send({ recebido: true });
});
};
Repara no uso de crypto.timingSafeEqual(). Uma comparação normal de texto (===) interrompe-se no primeiro carácter errado, permitindo que atacantes descubram a chave medindo tempos em nanossegundos. A comparação em tempo constante bloqueia essa técnica.
Travar Ataques de Repetição com Janelas de Tempo
Uma assinatura prova que a mensagem foi gerada pelo Stripe, mas não prova que foi enviada agora.
Imagina que um atacante intercepta a comunicação: ele não consegue mudar o valor da compra, mas pode guardar o pedido assinado e reenviar exatamente a mesma mensagem cinquenta vezes ao longo da semana:
ATAQUE DE REPETIÇÃO: REENVIAR PEDIDOS VÁLIDOS
- Dia 1: O Stripe envia um webhook de reembolso de 50€: POST /webhook (Reembolso 50€) → O teu servidor processa os 50€.
- (O atacante grava o pedido com assinatura)
- Dia 4: O atacante reenvia o mesmo pacote gravado: POST /webhook (Reembolso 50€) → A assinatura continua matematicamente válida!
- A tua aplicação reembolsa MAIS 50€! O invasor esvazia a tua conta!
Para destruíres este ataque, aplicas duas proteções em simultâneo:
- A Janela de Tolerância de 300 Segundos: Se a diferença entre a hora atual e o carimbo
tfor superior a 5 minutos, rejeitas o pedido de imediato. - Idempotência por Identificador de Evento: Guardas o identificador do evento (
evt_1045...) no Redis ou PostgreSQL. Se receberes o mesmo evento dentro do prazo de 5 minutos, sabes que já foi processado e ignoras a repetição, cumprindo a filosofia de anti-over-engineering.
Boas Práticas Operacionais: Velocidade de Resposta e Rotação de Chaves
Seguindo as normas de engenharia comprovadas na WebXtek, aplica estas três recomendações nos teus websites de alta performance:
- Responde em Menos de 500 Milissegundos: A maioria das plataformas corta a ligação ao fim de 5 segundos. Não tentes gerar PDFs ou enviar emails pesados dentro da rota do webhook. Valida a assinatura, coloca o evento numa fila assíncrona (como BullMQ) e devolve o
200 OKde imediato. - Suporta Rotação Gradual de Chaves: Quando precisares de mudar a chave secreta do webhook, mantém o suporte temporário a duas chaves no código para evitar rejeitar pedidos legítimos durante a transição.
- Nunca Guardes Chaves Privadas nos Registos de Erro: Em caso de falha de validação, regista apenas os primeiros seis caracteres do ID do evento para depuração. Nunca imprimas os cabeçalhos completos com segredos no ficheiro de logs.
Com assinaturas HMAC-SHA256 sobre bytes em bruto e validação de carimbos temporais, a tua aplicação fica imune a dados forjados e compras simuladas, garantindo total integridade nos pagamentos da tua empresa.
Na prática, aplicas exatamente estes padrões operacionais em setores exigentes como saas empresarial b2b e cibersegurança b2b e inteligência corporativa. Quando alinhas a tua estrutura através de web apps e websites de alta performance, transformas boas práticas técnicas numa vantagem competitiva real e mensurável para o teu negócio.
[+] [ INDEX_DIRECTORY ] 14 tópicos ▼
- A Ilusão da URL Secreta: O Risco dos Webhooks Abertos
- A Proteção Criptográfica: Como Funciona o HMAC-SHA256
- A Rasteira do JSON: Porque Tens de Validar os Bytes em Bruto
- Implementação Prática em Node.js com Fastify
- Travar Ataques de Repetição com Janelas de Tempo
- Boas Práticas Operacionais: Velocidade de Resposta e Rotação de Chaves
- Autor
- Perguntas Frequentes
- Porque é que um endpoint de webhook público está em perigo sem assinaturas?
- Como funciona a assinatura HMAC-SHA256 na prática?
- Porque tens de validar os bytes originais e não o JSON já processado?
- O que é um ataque de repetição (replay attack) num webhook?
- Nós Relacionados
- Sequência do Caminho
Perguntas Frequentes
Porque é que um endpoint de webhook público está em perigo sem assinaturas?
Como o endereço do webhook tem de estar aberto na internet para receber notificações de serviços externos (como pagamentos do Stripe), qualquer pessoa que descubra o URL pode enviar pedidos POST com dados falsos, marcando compras como pagas sem ter transferido dinheiro.
Como funciona a assinatura HMAC-SHA256 na prática?
O fornecedor e o teu servidor partilham uma chave secreta. O emissor calcula um hash criptográfico (HMAC-SHA256) sobre os bytes do corpo do pedido e envia o resultado num cabeçalho. O teu servidor recalcula o hash e faz uma comparação de tempo constante para validar a autenticidade.
Porque tens de validar os bytes originais e não o JSON já processado?
Os descodificadores de JSON alteram espaços em branco e a ordem das propriedades no objeto. Se recalcularares a assinatura sobre o JSON re-serializado em vez do buffer de bytes em bruto recebido na rede, a assinatura nunca coincidirá e falhará sempre.
O que é um ataque de repetição (replay attack) num webhook?
Num ataque de repetição, um invasor intercepta um pedido de webhook legítimo e volta a enviá-lo mais tarde para o teu servidor várias vezes. Ao adicionares uma janela de tolerância de tempo (por exemplo 300 segundos), a tua aplicação rejeita automaticamente pedidos com carimbos antigos.
[ RELATED_NODES ]
> INICIAR_PROJETO
Precisa de um website que transmita confiança, apareça na pesquisa e dê mais força à sua presença digital? Comece a conversa aqui.