Webhook assinado com HMAC: liga os teus leads bem, sem formulário improvisado
Tens um novo SaaS que cospe leads, e a tentação é sempre a mesma: um endpoint público que aceita qualquer POST JSON, logo se vê a segurança mais tarde. Três semanas depois, perguntas-te porque tens 400 leads falsos e um concorrente que conhece o teu volume de inscrições. A primitiva certa existe, cabe em 30 linhas, e é o webhook assinado com HMAC: corpo cru, assinatura SHA-256, anti-replay por timestamp. Foi assim que o ligámos no Zylior para os leads de entrada de todos os nossos SaaS.
Porque um formulário improvisado não aguenta
Um endpoint `/api/ingest` que confia no corpo do pedido tem três buracos, e todos são exploráveis sem seres um atacante talentoso:
- Qualquer um pode postar. O URL vaza num log, num Slack, num separador de rede do browser, e metes leads falsos diretamente na base de dados. Um segredo num query param (`?key=...`) não salva nada: acaba nos logs de acesso do reverse-proxy e no histórico do browser.
- Não sabes se o corpo foi modificado. Sem assinar o conteúdo, um proxy mal configurado ou um MITM pode mudar um email, um montante, um `ref` de afiliação, e tu nem dás por isso.
- O replay é de borla. Mesmo com um segredo, se eu capturar um pedido válido uma vez, reenvio-o 500 vezes e estrago-te o CRM — a não ser que assines também um timestamp e recuses os pedidos demasiado antigos.
O HMAC resolve os três de uma vez: o emissor calcula `HMAC-SHA256(corpo, segredo_partilhado)` e envia-to num header. Tu recalculas com o mesmo segredo. Se bater certo, o corpo não se mexeu e o emissor conhece o segredo. Acrescenta o timestamp ao cálculo e fechas a porta ao replay.
Lado emissor: assinar o corpo cru
O ponto que parte 80 % das integrações: tens de assinar os bytes exatos enviados na rede, não um objeto re-serializado. O `JSON.stringify` não é determinista entre dois runtimes (ordem das chaves, espaços, escape de Unicode). Se o emissor assina uma string e o recetor volta a fazer stringify do JSON parseado, as assinaturas vão divergir num payload em cada dez — o que tem um acento ou um emoji. Assinas a string uma vez, envia-la tal como está.
import { createHmac } from 'node:crypto';
function pushLead(payload, secret, url) {
// 1. Le corps BRUT, figé une seule fois.
const body = JSON.stringify(payload);
const ts = Math.floor(Date.now() / 1000).toString();
// 2. On signe timestamp + corps (le ts entre dans le HMAC : il ne
// peut pas être bidouillé sans casser la signature).
const mac = createHmac('sha256', secret)
.update(ts + '.' + body)
.digest('hex');
return fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-zylior-signature': mac, // hex brut
'x-zylior-timestamp': ts,
},
body, // EXACTEMENT la string signée
});
}
Lado recetor: verificar em tempo constante + anti-replay
A verificação tem duas armadilhas inegociáveis. Primeiro, recupera o corpo cru ANTES de a tua framework o parsear para JSON: o `express.json()` consome o stream, e quando chega ao teu handler o corpo original desapareceu. Captura os bytes no middleware através da opção `verify` e guarda-os em `req.rawBody`. Segundo, nunca `assinaturaCalculada === assinaturaRecebida`: a igualdade de string sai ao primeiro byte diferente, o que vaza a assinatura esperada por medição de tempo. Usa o `timingSafeEqual` — mas ele lança exceção se os buffers não tiverem o mesmo comprimento, por isso testa o comprimento primeiro.
// Capter le corps brut AU PARSE, sinon il est perdu :
app.use(express.json({
limit: '2mb',
verify: (req, _res, buf) => { req.rawBody = buf; },
}));
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyHmac({ secret, rawBody, signature, timestamp, toleranceSeconds }) {
if (!signature) return { ok: false, reason: 'signature manquante' };
// Anti-replay : dès qu'une fenêtre est configurée, le timestamp est
// OBLIGATOIRE (un endpoint signé sans ts = rejeu illimité).
if (toleranceSeconds > 0) {
const ts = Number(timestamp);
if (!Number.isFinite(ts)) return { ok: false, reason: 'timestamp invalide' };
if (Math.abs(Date.now() / 1000 - ts) > toleranceSeconds) {
return { ok: false, reason: 'hors tolerance (replay ?)' };
}
}
const expected = createHmac('sha256', secret)
.update(timestamp + '.' + rawBody.toString())
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature.trim());
if (a.length !== b.length) return { ok: false, reason: 'non concordante' };
return timingSafeEqual(a, b) ? { ok: true } : { ok: false, reason: 'non concordante' };
}
As armadilhas que te vão custar uma noite
- Fuso horário e relógio. O timestamp assina-se e compara-se em epoch UTC (segundos), nunca em hora local. Um emissor num servidor mal acertado, ou que envia milissegundos onde esperas segundos, e tudo passa a «fora de tolerância». Uma tolerância de ±300 s absorve o desvio de relógio normal sem abrir uma janela de replay útil.
- Corpo modificado em trânsito. Um proxy que «embeleza» o JSON (reformatação, BOM, conversão de codificação) muda os bytes e parte a assinatura. É uma boa notícia: a assinatura fez o seu trabalho. Faz log do `reason` da recusa para distinguir isto de um segredo errado.
- Idempotência. O anti-replay bloqueia o replay malicioso, mas um emissor legítimo em fire-and-forget vai reenviar o mesmo lead num timeout de rede. Põe uma `dedup_key` estável (o id de negócio, senão um hash do corpo cru) com um `ON CONFLICT DO NOTHING`.
- Validação DEPOIS da assinatura. Uma vez validado o HMAC, o corpo continua a ser uma entrada pública: valida-o (zod). Armadilha traiçoeira: `z.string().url()` aceita `javascript:` e `data:` — se um campo é um URL, impõe `.regex(/^https?:\/\//)`.
- Responde depressa. Devolve 200 em menos de 50 ms, mesmo num duplicado. Os emissores fire-and-forget repetem perante a lentidão, o que te cria falso replay. Faz o trabalho pesado depois de acusar a receção.
Montar sem te deixares enganar
Gera um segredo por endpoint (32+ bytes aleatórios), cifra-o em repouso na base de dados, expõe-o uma só vez ao cliente com o URL e um snippet pronto a copiar. Configura o esquema de assinatura, o header e a tolerância por dados — nada de `if (slug === 'renfort')` em hardcoded: adicionar um SaaS deve ser um INSERT, não um deploy. E escreve os quatro testes que contam: um payload assinado válido passa, o mesmo reenviado fora da janela é recusado, um byte modificado é recusado, uma assinatura do comprimento certo mas falsa é recusada. Enquanto esses quatro casos não estiverem verdes, o teu webhook não está ligado de forma limpa — está apenas aberto.
A newsletter
Ao subscreveres aceitas receber a newsletter da Zylior. Cancelamento em 1 clique em cada email.