zylior
← Blog

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:

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
  });
}
Duas escolas para o formato de assinatura: hex cru (`a1b2c3…`) ou com prefixo (`sha256=a1b2c3…`, o estilo Meta/Stripe). Escolhe um, documenta-o e mantém-no configurável no lado recetor — no dia em que ligares um emissor terceiro que imponha o outro, não vais querer fazer novo deploy.

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' };
}
Uma regra que nos impomos: um endpoint «assinado mas sem timestamp» é RECUSADO, não aceite em silêncio. Se declaras uma tolerância e o header de timestamp falta, devolve 401. Caso contrário tens a ilusão de anti-replay sem o anti-replay.

As armadilhas que te vão custar uma noite

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.