Verificar firmas
Cómo se firma
El contenido firmado es la cabecera webhook-id, la cabecera webhook-timestamp y el cuerpo en bruto de la solicitud, unidos con puntos: <id>.<timestamp>.<body>. La clave es tu secreto sin su prefijo whsec_, decodificado de base64. webhook-signature contiene v1, seguido del HMAC-SHA256 en base64 de ese contenido.
Mientras un secreto rotado está en su periodo de gracia, webhook-signature lleva dos firmas separadas por un espacio, una por secreto; acepta la entrega cuando coincida cualquiera de ellas.
- Verifica el cuerpo en bruto exactamente como se recibió, antes de analizar el JSON: volver a serializarlo cambia los bytes.
- Rechaza un webhook-timestamp que difiera más de 5 minutos de tu reloj, para que una solicitud capturada no se pueda repetir más tarde.
- Compara las firmas en tiempo constante.
- Responde 2xx rápido y haz el trabajo lento después: esperamos 10 segundos.
Con una biblioteca de Standard Webhooks
Los paquetes standardwebhooks de npm y PyPI (y las bibliotecas de la mayoría de los demás lenguajes) comprueban la firma y la marca de tiempo en una sola llamada y lanzan un error cuando alguna es incorrecta.
// npm install standardwebhooksimport { Webhook } from "standardwebhooks";const wh = new Webhook(process.env.DOTWICK_WEBHOOK_SECRET); // whsec_…// Express: app.post("/dotwick", express.raw({ type: "application/json" }), handler)function handler(req, res) { let event; try { event = wh.verify(req.body.toString("utf8"), { "webhook-id": req.header("webhook-id"), "webhook-timestamp": req.header("webhook-timestamp"), "webhook-signature": req.header("webhook-signature"), }); } catch { return res.status(400).end(); } res.status(204).end(); // answer first, then work queue.add(event.type, event);}A mano
Sin dependencias: toda la comprobación son unas pocas líneas de la biblioteca estándar.
import { createHmac, timingSafeEqual } from "node:crypto";function verifyDotwick(secret, headers, rawBody) { const id = headers["webhook-id"]; const timestamp = headers["webhook-timestamp"]; const signatures = headers["webhook-signature"] ?? ""; if (!id || !timestamp) return false; if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64"); const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest(); return signatures.split(" ").some((entry) => { const [version, signature] = entry.split(","); const received = Buffer.from(signature ?? "", "base64"); return ( version === "v1" && received.length === expected.length && timingSafeEqual(received, expected) ); });}