Guía11 min

Verificar la firma de webhooks en un agente de IA

Resumen

Un webhook sin firma es un endpoint público: cualquiera puede inventar un mensaje de Slack, Telegram o GitHub y disparar tools. Esta guía compara HMAC de GitHub, Slack, Meta y el secret_token de Telegram, con código Node, replay window y el error de hashear el JSON parseado.

SlackTelegramGitHub
Flujo editorial de un webhook que se verifica con HMAC antes de entrar al loop del agente

Qué resuelve

Esta pieza se queda en la decisión práctica: qué instalar, qué riesgo agrega y cómo aplicarlo sin romper operación.

Un agente en producción no empieza en el prompt. Empieza en quién puede hablarle. Si el endpoint /webhooks/* acepta el body sin verificar origen, un atacante manda un JSON inventado, el loop llama tools y pagas tokens —o peor, ejecutas una acción. Idempotencia (no cobrar/enviar dos veces cuando el proveedor reintenta) es otro problema; aquí el único tema es autenticar el request antes de encolarlo.

La arquitectura mínima —validar, normalizar, encolar— está en webhooks, colas y memoria. El paso 1 de “validar” es esta firma.

Qué verifica cada canal

No hay un HMAC universal. Cada plataforma firma distinto y no puedes reutilizar el verificador.

CanalHeaderQué se firmaSecretoReplay
GitHubX-Hub-Signature-256 (sha256=…)body crudowebhook secretno (usa delivery id aparte)
SlackX-Slack-Signature (v0=…)v0:{timestamp}:{raw body}signing secretsí: ±5 min vs X-Slack-Request-Timestamp
Meta / WhatsApp CloudX-Hub-Signature-256 (sha256=…)body crudoApp Secretno
TelegramX-Telegram-Bot-Api-Secret-Tokenno hay HMAC: igualdad con secret_token de setWebhooktoken 1–256 chars [A-Za-z0-9_-]no

Fuentes: GitHub, Slack, Telegram Bot API (setWebhook / secret_token), Meta Webhooks.

GitHub todavía puede enviar X-Hub-Signature (SHA-1). No lo uses. Slack documenta la ventana de cinco minutos; si omites el timestamp, un request capturado se reenvía días después.

Telegram no firma el body. El secret_token solo prueba que quien llama conoce el valor que tú pasaste a setWebhook. Combínalo con HTTPS y, si puedes, restricción de IP. El bot de Telegram en sí está en bot de Telegram con IA.

Meta además exige el handshake GET: hub.mode=subscribe, hub.verify_token igual al que configuraste, responder hub.challenge en texto plano. Sin eso no hay POST firmados.

Pipeline: request entra, HMAC o secret_token, 401 o cola del agente

El error que rompe todas las firmas

HMAC se calcula sobre los bytes exactos que envió el proveedor. Si parseas JSON y vuelves a JSON.stringify, cambian espacios y el orden de claves: la firma nunca coincide y, peor, algunos equipos “arreglan” eso desactivando la verificación.

En Next.js App Router lee el raw body una vez:

export const runtime = "nodejs";

export async function POST(req: Request) {
  const raw = Buffer.from(await req.arrayBuffer());
  // verificar raw; recién después JSON.parse(raw.toString("utf8"))
}

En Express, express.json() consume el stream. Usa express.raw({ type: "application/json" }) en esa ruta, verifica, y parsea tú.

Compara con timingSafeEqual y misma longitud. Un === entre hex strings filtra por tiempo cuántos caracteres coinciden.

Código mínimo (Node crypto)

Un helper, cuatro adaptadores. El secreto vive en env, nunca en el repo —mismo criterio que prompt injection: no confíes en input externo.

import { createHmac, timingSafeEqual } from "node:crypto";

function safeEqual(a: string, b: string) {
  const ba = Buffer.from(a);
  const bb = Buffer.from(b);
  if (ba.length !== bb.length) return false;
  return timingSafeEqual(ba, bb);
}

function hmacHex(secret: string, payload: Buffer) {
  return createHmac("sha256", secret).update(payload).digest("hex");
}

export function verifyGitHubOrMeta(
  raw: Buffer,
  header: string | undefined,
  secret: string,
) {
  if (!header?.startsWith("sha256=")) return false;
  return safeEqual(`sha256=${hmacHex(secret, raw)}`, header);
}

export function verifySlack(
  raw: Buffer,
  timestamp: string | undefined,
  signature: string | undefined,
  secret: string,
  nowMs = Date.now(),
) {
  if (!timestamp || !signature) return false;
  const ts = Number(timestamp);
  if (!Number.isFinite(ts) || Math.abs(nowMs / 1000 - ts) > 60 * 5) return false;
  const base = `v0:${timestamp}:${raw.toString("utf8")}`;
  const expected = `v0=${createHmac("sha256", secret).update(base).digest("hex")}`;
  return safeEqual(expected, signature);
}

export function verifyTelegram(header: string | undefined, secret: string) {
  if (!header || !secret) return false;
  return safeEqual(header, secret);
}

Ruta Slack: si falla, 401 y no encoles. Slack reintenta; un 200 con body ignorado enseña al atacante que el endpoint “traga” basura. El ack del canal (típicamente < 3 s) es otro problema: verifica en milisegundos, encola, responde 200.

GitHub documenta el mismo HMAC para redeliveries. El X-GitHub-Delivery sirve para idempotencia, no para autenticar.

Prueba sin LLM

No necesitas un modelo para saber si la firma funciona. Un test de 20 líneas falla si alguien parsea antes de hashear:

import { createHmac } from "node:crypto";
import { verifyGitHubOrMeta } from "./verify";

const secret = "test-secret";
const raw = Buffer.from('{"ok":true,"id":"a1"}');
const header =
  "sha256=" + createHmac("sha256", secret).update(raw).digest("hex");

console.assert(verifyGitHubOrMeta(raw, header, secret) === true);
console.assert(
  verifyGitHubOrMeta(Buffer.from(JSON.stringify(JSON.parse(raw.toString()))), header, secret)
    === false || raw.equals(Buffer.from(JSON.stringify(JSON.parse(raw.toString())))),
);

Si el segundo assert te sorprende: a veces stringify coincide; a veces no. Por eso el contrato es raw, no “JSON canónico”.

Rutas: firma válida entra a la cola; body parseado, replay o header ausente → 401

Checklist de producción

  1. Un secreto distinto por canal y por entorno (preview ≠ production).
  2. Verificar antes de parsear, loguear o llamar al LLM.
  3. 401 si falta header, si el secreto está vacío o si timingSafeEqual falla.
  4. Slack: rechazar timestamp fuera de 5 minutos.
  5. GitHub/Meta: exigir sha256=; ignorar SHA-1.
  6. Telegram: secret_token en setWebhook y comparar el header; rota con un nuevo setWebhook.
  7. Meta: implementar el GET hub.challenge sin HMAC (es el handshake, no el evento).
  8. No loguear el body ni el secreto. Un log de “firma inválida” + deliveryId basta.
  9. Tests de tools sin red: firma buena, firma mala, body re-serializado, replay viejo.
  10. Preview deployments: no apuntes el webhook de producción a una URL de preview.

Multicanal (WhatsApp + Telegram + Slack) implica tres verificadores, no un if (token === process.env.WEBHOOK_TOKEN) compartido. El mapa está en deploy multicanal.

Preguntas frecuentes

¿HTTPS no alcanza? No. HTTPS autentica el canal hasta tu servidor, no al cliente. Cualquiera que descubra la URL puede POSTear.

¿Puedo verificar después de encolar para no bloquear el ack? No. Si encolas basura, el worker gasta tokens y puede ejecutar tools. Verifica síncrono; es CPU de microsegundos.

¿Y los webhooks de Vercel/GitHub Actions hacia el agente? Misma regla: HMAC o secret de cabecera. Un cron que pega a /run sin firma es equivalente a un webhook abierto.

¿Sirve un allowlist de IPs? Como defensa extra (Telegram publica rangos a veces), nunca como único control: los rangos cambian y no cubren proxies.

El siguiente paso

Copia el helper, apunta un webhook de prueba y manda un POST a mano: debe ser 401. Luego el de la consola de Slack/GitHub: 200 y un job en cola. El curso gratis de instalación arma el primer agente; el hub de seguridad y costo agrupa operación. Sin firma, el resto del playbook es teatro.