Verificar a assinatura
HMAC SHA-256 sobre t.corpo, janela de 5 minutos e código pronto em Node e Python.
Por que verificar
Seu endpoint de webhook é uma URL pública que aceita POST sem login. Sem verificar, qualquer um que descubra o endereço injeta um scan.completed com risco 0 e apaga do seu painel a brecha que a ronda de verdade encontrou. A assinatura resolve isso porque depende de um segredo que só você e a Guarita conhecem.
O cabeçalho
Toda entrega traz x-guarita-signature no formato t=<unix>,v1=<hmac hex>. O conteúdo assinado é <t>.<corpo cru>: HMAC SHA-256 com o segredo gwh_… do destino, em hexadecimal.
O timestamp entra na assinatura, não só num cabeçalho ao lado: solto, ele seria trocável, e alguém reenviaria o mesmo corpo com a mesma assinatura válida pra sempre. Rejeite entregas com t a mais de 300 segundos do seu relógio.
POST /guarita HTTP/1.1
Host: api.suaempresa.com.br
Content-Type: application/json
User-Agent: Guarita-Webhook/1
x-guarita-signature: t=1756483200,v1=6f2a9c1d4b8e7f30a5c2d1e9b4f6a7c8d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4c4
x-guarita-delivery: dlv_2f0f6a1b-6b4e-4a58-9b2a-1f0f2c3d4e5f
{"id":"evt_9f1c2a54-4c1e-4a0c-9a1f-7b6a2f0d8e33","event":"scan.completed","createdAt":"2026-08-29T14:22:07.481Z","data":{...}}Os seis passos
Leia o corpo cru, em bytes
Antes de qualquer parse.
JSON.parseseguido deJSON.stringifyreordena chaves e muda espaços — os bytes deixam de ser os assinados.Parseie o cabeçalho
Separe por vírgula, depois por
=. Peguet(inteiro) ev1(hex). Falta algum? Rejeite.Rejeite fora da janela
Se
|agora − t| > 300segundos, rejeite. É a defesa contra replay.Calcule o HMAC
SHA-256 de
`${t}.${corpo}`com o segredo do destino. O ponto entre o timestamp e o corpo faz parte do conteúdo.Compare em tempo constante
timingSafeEqualem Node,hmac.compare_digestem Python. Nunca===.Só então faça o parse
Assinatura válida? Agora sim o JSON vira objeto. Responda 2xx e processe depois.
Código pronto
A mesma função verificar(header, corpo, segredo) nas duas linguagens, com o handler em volta. O segredo vem de GUARITA_WEBHOOK_SECRET — nunca do código.
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const TOLERANCIA_S = 300;
/** header = "t=<unix>,v1=<hex>" · corpo = Buffer com os BYTES crus · segredo = gwh_… */
export function verificar(header, corpo, segredo) {
if (typeof header !== "string") return false;
// 2. parsear o cabeçalho
const partes = Object.fromEntries(header.split(",").map((p) => p.trim().split("=")));
const t = Number(partes.t);
const v1 = partes.v1;
if (!Number.isFinite(t) || typeof v1 !== "string" || !/^[0-9a-f]+$/i.test(v1)) return false;
// 3. rejeitar fora da janela (replay)
const agora = Math.floor(Date.now() / 1000);
if (Math.abs(agora - t) > TOLERANCIA_S) return false;
// 4. HMAC SHA-256 de "<t>.<corpo cru>"
const esperado = createHmac("sha256", segredo)
.update(`${t}.`)
.update(corpo) // Buffer, nunca o objeto parseado
.digest();
const recebido = Buffer.from(v1, "hex");
// 5. comparar em tempo constante
return esperado.length === recebido.length && timingSafeEqual(esperado, recebido);
}
const app = express();
// 1. express.raw entrega o corpo como Buffer — antes de qualquer parse.
app.post("/guarita", express.raw({ type: "application/json" }), (req, res) => {
if (!verificar(req.headers["x-guarita-signature"], req.body, process.env.GUARITA_WEBHOOK_SECRET)) {
return res.status(401).send(); // 4xx é definitivo: a Guarita não reenvia
}
// 6. só agora o JSON.parse
const evento = JSON.parse(req.body.toString("utf8"));
res.status(204).send(); // responde antes de processar
processar(evento).catch((err) => console.error("[guarita] falha ao processar", err));
});
app.listen(3000);Testar localmente
Não dá pra apontar um destino pra sua máquina (host interno é recusado no cadastro). Pra exercitar o handler antes de ligar o destino de verdade, gere a assinatura na linha de comando e faça o POST você mesmo:
# Segredo do destino (o gwh_… que a tela mostrou uma vez) e um corpo qualquer
SECRET="gwh_SEU_SEGREDO"
T=$(date +%s)
BODY='{"id":"evt_teste","event":"scan.completed","createdAt":"2026-08-29T14:22:07.481Z","data":{"scan_id":"scan_5e2a91c4","hostname":"app.exemplo.com.br","target":"https://app.exemplo.com.br","risk_score":41,"risk_label":"MODERATE_RISK","critical_count":1,"blocked":false}}'
# HMAC SHA-256 de "<t>.<corpo>", em hex
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
# A mesma entrega que a Guarita faria — contra o seu handler local
curl -i -X POST http://localhost:3000/guarita \
-H "content-type: application/json" \
-H "user-agent: Guarita-Webhook/1" \
-H "x-guarita-signature: t=$T,v1=$SIG" \
-H "x-guarita-delivery: dlv_teste" \
--data-raw "$BODY"Handler respondeu 204? Troque o corpo por um caractere e repita: agora precisa responder 401. Depois disso, exponha uma URL HTTPS pública (um túnel serve) e cadastre-a em Configurações → Desenvolvedores.
Erros que mais mordem
- Parsear antes de verificar. O framework já transformou o corpo em objeto; ao serializar de novo, chaves e espaços mudam e a assinatura falha. Use
express.raw,request.get_data()ou o equivalente. - Comparar com
===. Comparação comum para no primeiro byte diferente, e o tempo de resposta entrega a assinatura aos poucos. Use tempo constante. - Ignorar o
t. Sem a janela, uma entrega capturada vale pra sempre. - Usar o
user-agentcomo prova. Qualquer um mandaGuarita-Webhook/1. Só a assinatura prova a origem. - Relógio do servidor atrasado. Mais de 300 segundos de diferença e toda entrega legítima cai no passo 3. Sincronize com NTP.
x-request-id, se for sobre uma resposta.