Seguridad de webhooks
Cualquiera puede hacer un POST a una URL pública, así que verifica cada
entrega antes de actuar sobre ella. EvoMap firma cada webhook con un HMAC cuya
clave es el secret de firma que recibiste cuando
registraste el endpoint. Una petición que no pase la
verificación debe rechazarse.
La cabecera de firma
Cada entrega incluye:
X-EvoMap-Webhook-Signature: t=1718000000,v1=<hex-hmac>
t: la marca de tiempo Unix del momento en que se creó la firma.v1: HMAC-SHA256, codificado en hexadecimal, calculado sobre la cadena`${t}.${rawBody}`(la marca de tiempo, un.literal y luego el cuerpo sin procesar de la petición) usando tusecretde webhook como clave.
También se envía una cabecera heredada X-EvoMap-Signature: sha256=<hmac over body>
(HMAC solo sobre el cuerpo, sin marca de tiempo) por compatibilidad hacia atrás.
Prefiere X-EvoMap-Webhook-Signature: el esquema con marca de tiempo es el que te
permite rechazar repeticiones.
Verificar una entrega
Calcula el v1 esperado sobre `${t}.${rawBody}` y compáralo con el valor de
la cabecera en tiempo constante. Importan dos reglas:
- Firma sobre los bytes del cuerpo sin procesar, exactamente como se recibieron: verificar contra un objeto JSON reserializado fallará, porque el orden de las claves y los espacios en blanco difieren.
- Rechaza una entrega cuyo
testé fuera de tu ventana de tolerancia (por ejemplo, ±5 minutos) para protegerte de capturas repetidas.
import { createHmac, timingSafeEqual } from "node:crypto";
/**
* @param {string} rawBody - the exact request body bytes
* @param {string} header - value of X-EvoMap-Webhook-Signature
* @param {string} secret - your webhook signing secret (whsec_…)
* @param {number} toleranceSec
* @returns {boolean}
*/
export function verifyWebhook(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=")),
);
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 || "");
return a.length === b.length && timingSafeEqual(a, b);
}
import hmac, hashlib, time
def verify_webhook(raw_body: bytes, header: str, secret: str, tolerance=300) -> bool:
parts = dict(kv.split("=", 1) for kv in header.split(","))
t = int(parts.get("t", 0))
if not t or abs(time.time() - t) > tolerance:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
Lista de comprobación
- Lee primero el cuerpo sin procesar. Captura los bytes del cuerpo antes de que cualquier análisis de JSON o middleware del framework los reserialice.
- Compara en tiempo constante (
timingSafeEqual/hmac.compare_digest), nunca con==, para evitar canales laterales de temporización. - Aplica la ventana de marca de tiempo. Una firma válida con un
tobsoleto es una repetición; recházala. - Devuelve
2xxsolo después de verificar. Si la verificación falla, devuelve4xxy no hagas nada. - Mantén el secreto en el servidor. Rótalo (elimina y vuelve a registrar el webhook) si crees que puede haberse filtrado.
Relacionado
- Webhooks — registro y el secreto de firma de un solo uso
- Catálogo de eventos — el sobre que estás verificando
- Entrega y reintentos — qué desencadena una entrega rechazada