Verificando a assinatura do webhook
Toda entrega de webhook da Guarita vai assinada. Este capítulo mostra como conferir essa assinatura antes de confiar no conteúdo — e por que cada passo existe.
Se você ler só um capítulo desta documentação, que seja este.
Por que verificar
Seu endpoint de webhook é uma URL pública que aceita POST sem login. Quem descobrir o endereço — num log de proxy, num commit, num print de tela, num scan de rota — pode mandar um corpo JSON com a forma certa e se passar pela Guarita.
O que isso permite depende do que seu handler faz. Na prática, é sempre ruim:
- injetar um
finding.openedfalso e disparar um alerta no Slack do time às 3h da manhã; - injetar um
scan.completedcomrisk_score: 0ecritical_count: 0, apagando de um painel interno a brecha que a ronda de verdade encontrou; - fazer seu pipeline liberar um deploy que deveria ter sido travado.
A assinatura resolve isso porque depende de um segredo (gwh_…) que só a Guarita e você conhecem. Sem ele, ninguém consegue produzir um header válido para um corpo escolhido.
Verificação não é opcional: é o que separa "recebi um evento da Guarita" de "recebi bytes de alguém na internet".
O que chega em cada entrega
A Guarita faz um POST com content-type: application/json e estes cabeçalhos:
user-agent: Guarita-Webhook/1
content-type: application/json
x-guarita-signature: t=1756483200,v1=6f2a...c4
x-guarita-delivery: dlv_2f0f6a1b-6b4e-4a58-9b2a-1f0f2c3d4e5fO corpo é sempre este envelope:
{
"id": "evt_9c3f1e2a-1c44-4b8e-9a1e-7d0b2f6a5c31",
"event": "finding.opened",
"createdAt": "2026-08-29T14:00:00.000Z",
"data": {
"scan_id": "scan_a1b2c3",
"hostname": "app.exemplo.com.br",
"verdict": "breach",
"risk_score": 41,
"previous_risk_score": 78,
"opened": ["Painel de administração exposto sem autenticação"],
"resolved": []
}
}Dois identificadores, com papéis diferentes:
- `id` do corpo (
evt_…) identifica o evento. Ele é estável entre reentregas: se a mesma entrega for tentada de novo, o corpo vem com o mesmoid. É por ele que se deduplica. - `x-guarita-delivery` (
dlv_…) identifica a tentativa para aquele destino, e muda a cada retentativa do mesmo evento. Guarde-o nos seus logs: é o que distingue "recebi duas vezes o mesmo evento" (doisdlv_diferentes, mesmoevt_) de "meu handler rodou duas vezes". O painel mostra só o resultado da última tentativa de cada destino, não a lista de entregas.
O formato da assinatura
O cabeçalho é x-guarita-signature e tem a forma:
t=<unix em segundos>,v1=<hmac-sha256 em hex minúsculo>O que é assinado não é o corpo sozinho, é a concatenação do timestamp com o corpo cru, separados por um ponto:
<timestamp>.<corpo cru>Sobre essa string, um HMAC-SHA256 com o segredo do destino (o gwh_… mostrado uma única vez quando você cadastrou o endpoint), em hexadecimal.
Em pseudocódigo:
assinado = timestamp + "." + corpoCru
v1 = hex( hmac_sha256(segredo, assinado) )
cabeçalho = "t=" + timestamp + ",v1=" + v1O esquema v1= existe para permitir trocar o algoritmo no futuro sem quebrar quem já integrou: uma versão nova entraria como v2= ao lado, e você continuaria lendo o v1 enquanto migra. Por isso leia o header por chave, não por posição — não presuma que v1 é sempre o segundo campo.
Por que o timestamp entra dentro da assinatura
A pergunta natural é: por que não mandar o timestamp num cabeçalho separado, e assinar só o corpo?
Porque, solto, ele seria trocável. Um atacante que capturasse uma entrega legítima poderia mudar a data no cabeçalho ao lado e reenviar o mesmo corpo com a mesma assinatura — que continuaria válida, porque a assinatura não teria nada a ver com a data. A verificação de frescor viraria teatro: você conferiria um número que qualquer um pode reescrever.
Colocando o timestamp dentro do conteúdo assinado, mudar a data invalida a assinatura. Data e corpo passam a ser uma coisa só, e quem não tem o segredo não consegue separar as duas.
A janela de tolerância
A Guarita aceita — e você deve aceitar — assinaturas com no máximo 300 segundos (5 minutos) de diferença do relógio atual.
Isso é o que impede replay: sem janela, uma entrega capturada uma vez poderia ser reenviada para sempre, sempre com assinatura perfeitamente válida. Cinco minutos absorvem relógio dessincronizado entre servidores sem deixar a janela de reuso grande.
Um detalhe que evita confusão nas retentativas: o `t` é o momento do envio daquela tentativa, não o momento do evento. Cada tentativa é assinada de novo com o relógio daquele instante, sobre exatamente os mesmos bytes de corpo. Uma retentativa que sai 8 minutos depois chega com t novo e passa na janela normalmente. O momento em que o evento aconteceu está no corpo, em createdAt.
Os passos da verificação
- Pegue o corpo cru da requisição, em bytes, antes de qualquer parse.
- Leia
x-guarita-signature. Se faltar ou não tivertev1, rejeite. - Confira que
agora - testá dentro de 300 segundos (em módulo — umtno futuro também é suspeito). - Monte
"<t>." + corpoCrue calcule o HMAC-SHA256 com o segredo do destino, em hex. - Compare com o
v1recebido em tempo constante. - Só depois disso faça o
JSON.parsee trate o evento.
Ordem importa: nada de parsear, consultar banco ou disparar notificação antes do passo 5.
Código completo em Node.js e Express
Este arquivo roda como está. Coloque o segredo do destino em GUARITA_WEBHOOK_SECRET.
import express from "express";
import crypto from "node:crypto";
const app = express();
const SECRET = process.env.GUARITA_WEBHOOK_SECRET;
if (!SECRET) throw new Error("Defina GUARITA_WEBHOOK_SECRET");
const SIGNATURE_HEADER = "x-guarita-signature";
const TOLERANCE_SECONDS = 300;
/** Lê `t=…,v1=…` por chave, sem depender da ordem dos campos. */
function parseSignature(header) {
if (typeof header !== "string") return null;
let timestamp = null;
let signature = null;
for (const part of header.split(",")) {
const [k, v] = part.trim().split("=");
if (k === "t" && /^\d+$/.test(v ?? "")) timestamp = Number(v);
if (k === "v1" && /^[0-9a-f]+$/i.test(v ?? "")) signature = v.toLowerCase();
}
return timestamp !== null && signature ? { timestamp, signature } : null;
}
/** Compara dois hex de mesmo tamanho sem vazar quanto deles bateu. */
function safeEqualHex(a, b) {
const bufA = Buffer.from(a, "hex");
const bufB = Buffer.from(b, "hex");
// timingSafeEqual LANÇA se os tamanhos diferem — a checagem vem antes, sempre.
if (bufA.length !== bufB.length || bufA.length === 0) return false;
return crypto.timingSafeEqual(bufA, bufB);
}
/** `rawBody` é um Buffer com os bytes exatos que chegaram. */
function verifyGuaritaSignature(rawBody, header, secret) {
const parsed = parseSignature(header);
if (!parsed) return { ok: false, reason: "cabeçalho ausente ou malformado" };
const agora = Math.floor(Date.now() / 1000);
if (Math.abs(agora - parsed.timestamp) > TOLERANCE_SECONDS) {
return { ok: false, reason: "fora da janela de tolerância" };
}
// Conteúdo assinado: `<timestamp>.<corpo cru>`. Dois `update` em vez de montar uma
// string: assim os bytes do corpo entram como vieram, sem passar por conversão.
const esperado = crypto
.createHmac("sha256", secret)
.update(`${parsed.timestamp}.`)
.update(rawBody)
.digest("hex");
if (!safeEqualHex(esperado, parsed.signature)) {
return { ok: false, reason: "assinatura não confere" };
}
return { ok: true };
}
// express.raw entrega req.body como Buffer — os bytes originais, sem parse.
app.post(
"/webhooks/guarita",
express.raw({ type: "application/json" }),
(req, res) => {
const check = verifyGuaritaSignature(
req.body,
req.get(SIGNATURE_HEADER),
SECRET,
);
if (!check.ok) {
console.warn("[guarita] entrega recusada:", check.reason);
// 4xx: é definitivo, não adianta reenviar o mesmo corpo.
return res.status(400).send("assinatura inválida");
}
const evento = JSON.parse(req.body.toString("utf8"));
// Responda rápido e processe depois: cada tentativa tem 10s de timeout.
res.status(204).end();
// Deduplique por evento.id — a mesma entrega pode chegar mais de uma vez.
processarEmBackground(evento).catch((err) => {
console.error("[guarita] falha ao processar", evento.id, err);
});
},
);
async function processarEmBackground(evento) {
switch (evento.event) {
case "scan.completed":
console.log("ronda concluída", evento.data.hostname, evento.data.risk_score);
break;
case "finding.opened":
console.log("brecha nova em", evento.data.hostname, evento.data.opened);
break;
case "scan.failed":
console.log("ronda falhou", evento.data);
break;
}
}
app.listen(3000);Se o resto da sua aplicação usa express.json(), registre a rota do webhook antes do middleware global, ou monte o express.json() com { type: ... } que exclua esse caminho. Um express.json() global aplicado antes consome o corpo e você fica sem os bytes.
Os dois pontos onde quase todo mundo erra
1. Precisa ser o corpo cru, não o objeto parseado
A assinatura cobre bytes, não a estrutura de dados. Depois de JSON.parse, os bytes originais deixaram de existir — e JSON.stringify não os reconstrói.
// ERRADO: a assinatura nunca vai bater
app.use(express.json());
app.post("/webhooks/guarita", (req, res) => {
const corpo = JSON.stringify(req.body); // bytes DIFERENTES dos que chegaram
// ...
});JSON.stringify produz uma serialização canônica do seu runtime, e ela quase certamente difere da que a Guarita enviou: espaços em branco, quebras de linha, ordem das chaves, como caracteres não-ASCII foram escapados ("ç" versus "ç"), como números foram formatados. Um único byte diferente muda o HMAC inteiro.
O sintoma é cruel porque é silencioso e absoluto: toda entrega falha na verificação, e o corpo parece idêntico quando você imprime os dois lados no console. Se a sua verificação nunca passa e o segredo está certo, é quase sempre isto.
A forma correta é guardar os bytes:
// CERTO: req.body é um Buffer com os bytes que chegaram
app.post("/webhooks/guarita", express.raw({ type: "application/json" }), handler);Em outros frameworks o nome muda, a ideia não: Fastify tem addContentTypeParser com parseAs: "buffer"; no Next.js App Router use await request.text() antes de qualquer request.json(); em serverless há normalmente um event.body cru (cuidado com isBase64Encoded). O parse só acontece depois que a assinatura passou.
2. A comparação precisa ser em tempo constante
// ERRADO
if (esperado === recebida) { /* ... */ }=== em strings para na primeira diferença. Isso faz o tempo de resposta depender de quantos caracteres iniciais o atacante acertou — uma assinatura que começa certa demora um pouquinho mais para ser rejeitada que uma que erra no primeiro caractere.
Com esse retorno, o ataque deixa de ser "adivinhar 64 caracteres hexadecimais de uma vez" (inviável) e passa a ser "descobrir um caractere de cada vez, medindo o tempo" — 64 rodadas de 16 tentativas. A diferença é de nanossegundos, mas é estatisticamente mensurável com volume suficiente de requisições, e seu endpoint aceita quantas requisições o atacante quiser mandar.
crypto.timingSafeEqual compara os buffers inteiros sempre, independentemente de onde está a primeira diferença. Duas exigências ao usá-lo:
- Cheque o tamanho antes.
timingSafeEquallançaRangeErrorse os buffers têm tamanhos diferentes, e uma exceção não tratada aqui vira 500 — que a Guarita interpreta como falha temporária e continua reenviando. Vazar o tamanho não é problema: o tamanho de um HMAC-SHA256 é fixo e público. - Compare os bytes, não o hex como texto.
Buffer.from(x, "hex")ignora caracteres fora do alfabeto hexadecimal em vez de falhar, o que pode produzir dois buffers curtos de mesmo tamanho a partir de lixo. Por isso oparseSignatureacima só aceitav1que case com/^[0-9a-f]+$/i, e a comparação rejeita buffer vazio.
O que responder, e o efeito de cada resposta
A resposta do seu endpoint controla a retentativa:
| Resposta | O que a Guarita faz |
|---|---|
2xx | Considera entregue e para. |
4xx (exceto 429) | Trata como definitivo e não tenta de novo. |
429, 5xx, timeout, erro de rede | Reagenda e tenta de novo. |
As retentativas são até 6 tentativas no total, com esperas crescentes entre elas (15s, 30s, 1min, 2min, 4min). Cada tentativa tem 10 segundos de timeout — por isso o exemplo responde antes de processar.
Consequência prática de devolver 400 em assinatura inválida: se você errar a configuração do segredo, os eventos daquele período são descartados sem retentativa. É a escolha certa mesmo assim — reenviar um corpo cujo remetente não foi comprovado só gasta tentativa. Confira no painel, em Configurações → Desenvolvedores, o status e o erro da última entrega de cada destino antes de considerar a integração pronta.
Depurando pela linha de comando
Se a verificação não passa, recalcule a assinatura na mão a partir de uma entrega real. Salve o corpo exatamente como chegou, sem reformatar, e use o t do header daquela entrega:
T=1756483200
SECRET='gwh_...'
printf '%s.%s' "$T" "$(cat corpo-cru.json)" \
| openssl dgst -sha256 -hmac "$SECRET" \
| awk '{print $NF}'O resultado tem que ser idêntico ao v1 do header. O prefixo que o openssl imprime antes do hash varia com a versão, por isso o awk pega o último campo.
Se não bater, o problema quase sempre está no arquivo: um editor que salvou com quebra de linha no final, ou um "formatar JSON" que reindentou o conteúdo. Salve com curl --data-binary, ou registre o corpo cru direto do seu handler antes de qualquer tratamento.
Checklist de produção
- [ ] O segredo (
gwh_…) vem de variável de ambiente ou gerenciador de segredos — nunca do código nem de log. - [ ] O handler lê o corpo cru; nenhum parser de JSON roda antes dele.
- [ ] A assinatura é verificada antes de qualquer parse, consulta ou notificação.
- [ ] A janela de 300 segundos é conferida, nos dois sentidos (passado e futuro).
- [ ] A comparação usa
crypto.timingSafeEqual, com checagem de tamanho antes. - [ ] O endpoint é
https://e o certificado é válido. - [ ] Eventos são deduplicados por
iddo corpo (evt_…). - [ ] A resposta sai em menos de 10 segundos; o trabalho pesado vai para background.
- [ ] Assinatura inválida devolve
4xxe registra um aviso — silêncio aqui esconde ataque. - [ ] Você tem um segredo por destino, e sabe qual revogar sem derrubar os outros.