Ir al contenido

Blog ·

Una URL de webhook que es el secreto

Un webhook entrante puede ser su propia credencial si la URL soporta ese peso. Lo que cuesta: 160 bits, una firma sobre el reloj y un cuerpo cortado antes.

Por Provibr

Alguien quiere que su herramienta de monitorización inicie uno de nuestros flujos de automatización. Esa herramienta sabe enviar JSON a una URL, y nada más: sin firma, sin cabecera propia, sin certificado de cliente. Es un equipo en un rack, configurado en 2019, y no va a aprender nada nuevo.

Cabe rechazar a ese cliente o aceptar que, para algunas fuentes, la URL misma es la credencial. Se aceptó, y luego se dedicó la ronda entera a comprobar que esa URL soporta ese peso de verdad en lugar de limitarse a ser larga.

Treinta y dos caracteres, y nunca derivados de nada

El identificador de /api/hooks/flows/<slug> son treinta y dos caracteres de un alfabeto de treinta y dos símbolos: 160 bits de entropía en bruto. No se deriva de la cuenta, del flujo ni del nombre del extremo, porque un identificador calculable a partir de datos que el cliente ya publicó es calculable por cualquiera que lea su página de estado.

El alfabeto es base32 de Crockford: los dígitos y las minúsculas menos i, l, o y u. La misma elección que en los códigos de activación de los agentes, y por la misma razón. Esta cadena se teclea en el panel de administración de otra persona, y ninguna tipografía distingue l de 1 cuando alguien está cansado.

El secreto se cifra, no se resume

Los extremos pueden llevar además un secreto compartido, como firma HMAC o como testigo de tipo bearer. Ambos se guardan cifrados, y merece explicación, porque el instinto que dejan dos décadas de consejos sobre contraseñas aquí resulta exactamente equivocado.

Una contraseña admite un resumen porque solo hay que responder si es la misma cadena. Un secreto HMAC no: verificar significa calcular la firma, y para eso hace falta el texto claro. Entra pues bajo AES-256-GCM, con una clave derivada del secreto de la plataforma y con datos autenticados que nombran el extremo al que pertenece, de modo que un texto cifrado tomado de una fila no se verifica como secreto de otra. Es la misma maquinaria que ya protege las claves de pago y los secretos de los guiones; un segundo mecanismo supondría una segunda historia de rotación y un segundo lugar donde equivocarse.

Si esa fuente de clave cambia alguna vez, el texto cifrado no abre y la petición se rechaza en lugar de aceptarse; la pantalla dice entonces que hay que regenerar el secreto. Fallar en abierto ante un secreto ilegible es de esas líneas que se escriben una vez con prisa y se encuentran tres años después.

El cliente ve el secreto una sola vez, al crearlo. No porque no se pueda mostrar de nuevo, sino porque «anotado ya en dos sitios» y «visible para cualquiera con permisos de automatización» son riesgos distintos, y solo el primero es inevitable.

El reloj va dentro del material firmado

La firma es sha256=<hex> en una cabecera, sobre la marca de tiempo, un punto y el cuerpo en bruto. La marca viaja en su propia cabecera y debe caer dentro de cinco minutos de nuestro reloj, por ambos lados.

Poner la marca dentro del material firmado en lugar de al lado es todo el mecanismo contra la repetición. Si solo se firma el cuerpo, cualquiera que haya capturado una petición válida puede repetirla eternamente con una cabecera de tiempo nueva, y la ventana queda como adorno. Los dos lados importan igual: una comprobación que solo rechaza lo demasiado antiguo acepta una firma fechada en 2099.

La comparación se hace en tiempo constante, porque una igualdad corriente sobre texto hexadecimal termina en el primer carácter distinto, y ese tiempo basta para adivinar una firma byte a byte. Y la marca se juzga después de que la firma cuadre, nunca antes. Al revés, «caducada» sería un oráculo gratuito: alguien sin ningún secreto válido sabría que el problema estaba en su reloj y no en su firma. La distinción entre firma incorrecta y firma caducada existe, pero existe en el comprobante que lee el dueño del extremo, no en la respuesta que recibe quien llama.

Un cuerpo que se corta mientras llega

El tope del cuerpo son 64 KiB por omisión, y lo que importa no es la cifra sino el punto donde se aplica.

Leer la petición entera en una cadena y comprobar después su longitud es una comprobación posterior al daño: un cuerpo de 500 MB nos cuesta 500 MB antes del rechazo, y así el tope es una invitación. El cuerpo se lee por tanto en trozos desde el flujo, la suma corriente se compara en cada trozo, y el lector se cancela en cuanto la supera. Los bytes registrados son los que se leyeron de verdad, no la Content-Length que la fuente declaró, porque quien nos ataca es justo aquel cuya cabecera no conviene creer.

El freno de caudal va después del cuerpo y no antes, al contrario del consejo habitual, y así debe ser: la firma se calcula sobre el cuerpo, de modo que un freno que responde antes no puede saber si la petición era legítima. Cuenta por extremo y no por dirección de origen. Una fuente real de webhooks tiene una dirección; quien ataca tiene mil, así que frenar por dirección protege justo a la parte equivocada.

Nueve rechazos, doce desenlaces y ningún cuerpo

El orden de los rechazos es el diseño. Primero la forma del identificador, antes de tocar la base de datos, porque where slug = <un megabyte de texto> es trabajo que un desconocido no debería poder imponernos. Después existencia, revocación, desactivación, la lista de direcciones, el tope del cuerpo, el freno de caudal, la firma y, por último, si el JSON es siquiera un objeto.

Cada rechazo a partir del tercer paso escribe un comprobante. Esa tabla es la respuesta a «mi webhook no hace nada», una frase que esconde dos problemas muy distintos: no llega nada, o llega algo y se rechaza. Sin una fila no se distinguen, y el cliente dedica la tarde al problema equivocado.

El comprobante lleva el desenlace como palabra propia junto al estado HTTP, porque los códigos de estado son mal idioma para esto: un mismo 403 cubre «su dirección no está en la lista» y «este extremo está apagado», y eso envía a una persona a dos pantallas distintas. Doce desenlaces, solo añadibles, con la dirección de origen y los bytes leídos.

El comprobante nunca lleva el cuerpo: es la carga de otra persona, puede contener cualquier cosa, y una comodidad para depurar no compensa una tabla que acumula en silencio datos ajenos. Los comprobantes se guardan noventa días, deliberadamente menos que las ejecuciones a las que pertenecen, porque una dirección de origen es un dato personal de un tercero que no consintió nada.

Un rechazo no escribe nada: un identificador desconocido. No hay cuenta de la que colgar la fila, y una tabla que crece cada vez que un desconocido adivina es una forma gratuita de llenar nuestra base de datos.

Por qué 410 y 404 no son la misma respuesta

Revocar un extremo no lo borra. La fila permanece, el identificador queda ocupado para siempre, y una petición recibe 410 en vez de 404. El nombre vuelve a estar disponible, porque distinguir dos extremos vivos era todo el motivo de esa unicidad, pero la URL no vuelve nunca.

La alternativa tentadora es responder 404 a los dos casos, para que nadie sepa qué identificadores existieron. Adivinar uno son 160 bits de trabajo, así que quien tiene uno lo recibió, de una línea de registro, de una integración antigua o de una captura, y ya sabe que existió. La distinción no le dice nada.

A la mitad honrada del mundo le dice bastante. Una fuente que sigue enviando a la URL antigua tras una rotación ve en el 410 que necesita otra; ante un 404 concluye que alguien escribió mal la ruta, y entonces una persona busca un fallo en la ruta en lugar de en la rotación.

El primer disparador sin servidor

Todos los disparadores anteriores actuaban sobre una máquina existente: aprovisionada, reinstalada, eliminada. Una ejecución por webhook no tiene máquina, ni plan, ni dirección, ni integración, ni agente, ni nadie con la sesión iniciada.

Suena pequeño y fue el filo más afilado de la ronda. El catálogo de variables anota en cada grupo los disparadores para los que está disponible, y la constante que significaba «siempre» era literalmente la lista de todos los disparadores. Un quinto valor habría ofrecido entonces a un flujo por webhook todas las variables de servidor, plan, dirección, red y agente en el selector; el validador las habría aprobado, y la primera petición real habría caído al representar el texto, dentro de una ejecución que nadie mira.

Ahora hay pues una lista aparte que responde a otra pregunta: ¿corre este disparador contra un servidor? Un sexto disparador debe responderla primero. Lo que falta en una ejecución por webhook se poda del contexto en lugar de dejarse presente y vacío, y un nodo de acción en un grafo así se rechaza al guardar.

El cuerpo aterriza un nivel de profundidad, como texto, número o valor lógico. Los campos desconocidos se omiten en lugar de rechazarse, y ese es el único punto en que una carga de webhook difiere de una escrita por el propio cliente: una carga escrita a mano con una errata debe fallar, pero el mensaje de un tercero trae valores nulos, objetos anidados y listas porque nunca se escribió para nosotros, y rechazar la petición entera por un campo de más deja inservible cualquier fuente real.

Lo que anotaríamos

  1. Si la URL es la credencial, conviene hacerla una credencial. Aleatoria, no derivada, imposible de adivinar, revocable, jamás reemitida.
  2. Un secreto con el que se calcula no admite resumen. Cifrarlo, atarlo a su dueño y fallar en cerrado cuando no abra.
  3. Firmar el reloj, no solo el cuerpo. Una marca de tiempo fuera de la firma es una defensa que no defiende nada.
  4. Aplicar el tope de tamaño mientras llegan los bytes. Una comprobación posterior a la lectura ya pagó el coste.
  5. Frenar lo que configuró el cliente, no lo que controla quien ataca.
  6. Registrar los rechazos, no la carga. Los rechazos hacen respondible «no pasa nada»; la carga es dato ajeno.
  7. Si un caso nuevo carece de sujeto, buscar toda lista que significara «todos los casos». Una de ellas es sinónimo de la enumeración, y no lo dice.

Para que quede claro: los extremos, los esquemas, los comprobantes y el disparador de webhook están construidos y funcionando. Queda sin demostrar el respaldo en memoria del freno ante una caída real de la caché, el comportamiento tras cambiar el secreto de la plataforma, y todo ello en el dominio propio de un cliente.

Del trabajo de plataforma en Provibr. Cómo se mantiene finito el grafo que hay detrás del disparador está en un flujo es un grafo, y un grafo debe ser finito, y cómo el lienzo obtuvo su cable está en un cable que se arrastra, y un solo disparador.

Empiece con un host y un agente

Cree una cuenta, instale el agente con un solo comando y conecte su primer host de Proxmox. El periodo de prueba dura 14 días y no pide ningún dato de pago.