Tus webhooks no deberían confiar en la URL que reciben

Product

Un webhook es la única función en la que un cliente escribe una dirección en un formulario y tus servidores van y la llaman. Cualquier otra solicitud que hace tu backend va a un sitio que elegiste tú. Esta va adonde diga el cliente, tan a menudo como tu producto genere eventos y mientras exista el endpoint.

Esta semana Kookee lanzó los webhooks y, sobre ellos, integraciones con Slack, Discord, Telegram, GitHub, Jira, Asana y Freshdesk. Publicas una entrada y tu sitio estático se reconstruye; un visitante deja un comentario y llega al canal de Slack de tu equipo o se convierte en una tarea de Jira.

La página Integraciones con GitHub Issues, Slack y Jira conectados, y Telegram, GitHub Actions y Discord listos para configurar

El formulario con el campo de la URL me llevó una tarde. Todo lo que sigue llevó el resto de la semana, y algunas de estas cosas solo las detecté al revisar el plan, antes de que existiera una sola línea de código.

Ocho cosas que un emisor de webhooks tiene que hacer bien

1. Comprueba la dirección al enviar, no al guardar

La comprobación obvia es rechazar http://localhost y 10.0.0.5 cuando alguien guarda un endpoint. Y es la que no sirve de nada. La URL se comprueba una vez, pero el nombre que contiene se resuelve de nuevo en cada entrega, y nada impide que hooks.example.com apunte a una dirección pública el lunes y a 169.254.169.254, el servicio de metadatos de la nube, el martes.

Así que la comprobación de verdad ocurre al enviar. Kookee resuelve el host, rechaza la entrega si alguna de las direcciones que obtiene es privada, de loopback, link-local o reservada (incluido IPv4 escrito dentro de IPv6), y después se conecta exactamente a la dirección que comprobó, para que una segunda resolución no pueda cambiarla. Las redirecciones no se siguen: un 302 a una dirección interna es solo una respuesta.

Hay un rango fácil de olvidar: las direcciones públicas de tus propios servidores. Detrás de una CDN, la IP de tu origen es justo lo que más quiere alcanzar un atacante, y un webhook la llamaría sin problema desde dentro de tu firewall. Las de Kookee están en la lista de bloqueo.

2. Firma como lo hacen todos los demás

Podría haber inventado una cabecera de firma. En lugar de eso, Kookee firma las solicitudes como indica Standard Webhooks: un webhook-id, un webhook-timestamp y un HMAC en webhook-signature. Quien escribe el receptor tiene una biblioteca en su lenguaje, una ventana de cinco minutos contra ataques de repetición sin hacer nada y un formato menos que aprender.

El secreto de firma se guarda cifrado y se puede mostrar y regenerar desde el menú del endpoint. Al regenerarlo, el anterior deja de valer al instante, que es lo que quieres tras una filtración.

3. Un receptor lento solo debería frenarse a sí mismo

Cada entrega espera al servidor de otro. Si el endpoint de un cliente tarda nueve segundos en responder y tus workers procesan unas pocas entregas a la vez, un solo cliente se convierte, sin que nadie lo note, en la cola de todos.

Cada solicitud tiene un plazo estricto de 10 segundos, resolución DNS incluida, y las entregas se ejecutan de 25 en 25 por proceso. Eso solo no basta, porque un endpoint lento al que se reintenta todo el día sigue ocupando esos huecos. Por eso cada endpoint tiene 2 minutos y medio de espera por hora, y cada cuenta 7 minutos y medio entre todos sus endpoints. Una entrega que supera ese presupuesto se omite, y el endpoint lo explica con palabras claras. Una omisión nunca cuenta como fallo: el receptor es lento, no está roto, y desactivarlo castigaría lo que no es.

Los eventos de contenido y los de visitantes se cuentan por separado. Una avalancha de comentarios spam nunca debería ser la razón de que tu sitio no se reconstruyera después de publicar.

4. Reintenta durante horas, luego para y avisa a alguien

Los servidores se caen por un despliegue y vuelven. Una entrega que falla se reintenta diez veces a lo largo de unas ocho horas y media, con esperas de 1, 2, 4 y hasta 256 minutos, y con el mismo webhook-id cada vez para que el receptor pueda descartar duplicados.

Lo que no quería era un endpoint que llevara fallando en silencio desde marzo. Una respuesta 410 Gone desactiva el endpoint al instante. Cinco eventos seguidos que fallan en todos los reintentos también lo desactivan. En ambos casos el propietario del proyecto recibe una notificación y un email que dicen qué endpoint y por qué, y reactivarlo es un clic.

5. Envía el evento cuando cambia la página, no cuando cambia la fila

«Entrada actualizada» parece sencillo hasta que cuentas lo que lo dispara. El autoguardado, una errata corregida en un borrador, una traducción que se vincula: un hook ingenuo reconstruye un sitio estático cuarenta veces en una tarde.

Los eventos de contenido de Kookee siguen lo que un visitante puede ver. entry.published se dispara cuando una entrada se hace visible, y eso incluye una publicación programada que sale a las 9:00 sin nadie al teclado: cada fecha futura tiene una tarea esperándola. entry.updated se dispara solo para una entrada que era visible y lo sigue siendo. Un borrador guardado diez veces no envía nada.

6. Cada destino tiene su propia forma de avisar a la gente

Las integraciones publican en las herramientas de tu equipo texto que escribió un desconocido, y cada herramienta tiene su propio marcado para llamar la atención. Un comentario titulado <!channel> urgent avisaría a todo un canal de Slack. En Discord el mismo truco es @everyone, más un enlace enmascarado que oculta adónde lleva. En GitHub, #123 u owner/repo#1 en el cuerpo de un issue deja una referencia cruzada en el issue de otra persona, desde tu cuenta.

Por eso cada servicio tiene su propio escapado. En Slack se escapan los caracteres especiales y la vista previa de enlaces está desactivada. En Discord se escapan todos los caracteres de Markdown, se desactivan las menciones y se suprimen los embeds. El texto para Telegram se escapa como HTML. En un issue de GitHub, el texto del visitante va en un bloque de código con un delimitador más largo que cualquier serie de comillas invertidas que contenga. Freshdesk recibe texto escapado como HTML, porque el cuerpo de sus tickets es HTML. Y Jira rechaza una descripción con un párrafo vacío, así que las líneas en blanco se eliminan antes de llegar.

Nada de esto se ve cuando funciona. Es la diferencia entre una herramienta de comentarios y una forma de que cualquiera escriba a toda tu empresa.

7. Las credenciales nunca viajan en la cola

La URL de un webhook de Slack es una contraseña: cualquiera que la tenga puede publicar en tu canal. Lo mismo pasa con el token de un bot de Telegram, que va dentro de la URL de la API. El diseño tentador mete la configuración descifrada en la tarea de entrega para que el worker tenga todo lo que necesita. Entonces cada token queda en Redis en texto plano, en cada reintento, durante horas.

Las tareas de Kookee llevan un id de integración, un id de evento y el evento. El worker carga la fila, descifra la configuración en el último momento y nunca escribe la URL de una solicitud en un log. Los tokens tampoco entran en el registro de auditoría, que guarda quién cambió qué, pero no el secreto que escribió.

8. Lee las reglas del otro lado antes de escribir las tuyas

Mi primer plan trataba 403 Forbidden como «esta integración nunca va a funcionar» y la desactivaba. El cliente HTTP de Node no envía cabecera User-Agent si no la defines, y GitHub responde con 403 a una solicitud sin ella. Todas las integraciones de GitHub se habrían desactivado solas en su primera entrega.

La revisión del plan lo detectó, y ahora cada solicitud lleva content-type y un User-Agent. La lección fue más allá de una cabecera: GitHub también responde con 403 cuando se supera su límite de solicitudes, así que ese caso se reintenta, y Telegram informa de un chat eliminado con un 400 y un mensaje, así que ese caso desactiva la integración. Cada servicio tiene una lista corta de excepciones a una regla simple, escrita junto al código que le envía.

Si construyes webhooks

  • Resuelve el host al enviar, rechaza direcciones privadas y reservadas, conéctate a la dirección que comprobaste y no sigas redirecciones.

  • Pon las direcciones públicas de tus propios servidores en la lista de bloqueo.

  • Firma con Standard Webhooks en lugar de con un formato propio.

  • Dale a cada entrega un plazo estricto, y a cada endpoint y cada cuenta un presupuesto de tiempo, para que un receptor lento no pueda frenar a todos.

  • Reintenta durante horas con un id de mensaje estable, y después desactiva el endpoint y avisa a su propietario.

  • Dispara los eventos de contenido según lo que ven los visitantes, no en cada guardado.

  • Escapa el texto para el marcado de cada destino antes de publicarlo.

  • Mantén las credenciales fuera de la cola, de los logs y del registro de auditoría.

Los webhooks están en todos los planes de Kookee, y las integraciones en Pro y Ultra. Envía eventos a tu sitio con webhooks explica los eventos, el payload y cómo verificar la firma.

Sigue leyendo