El límite de inicio de sesión de tu dashboard se podía falsificar con una sola cabecera

Pasaste una tarde poniendo el dashboard web de Hermes detrás de nginx, configuraste una contraseña, activaste HTTPS y expusiste el puerto a internet. Te dijiste que estaba bien: el endpoint de inicio de sesión permite diez intentos por minuto, así que la fuerza bruta es matemáticamente imposible. Y resulta que al atacante solo le hace falta enviar un valor X-Forwarded-For distinto en cada petición para conseguir un número ilimitado de intentos — porque el límite de tasa se agrupa por “client IP”, y la IP del cliente venía de una cabecera que el propio atacante escribía.
El 5 de octubre de 2026, el repositorio de Hermes fusionó en un solo día tres correcciones de endurecimiento para la autenticación del dashboard (PRs #133367, #133369, #133370). Ninguna es una funcionalidad. Las tres son la misma clase de error sobre el mismo punto de entrada: entrada sin techo, confiada porque llegó. Esto es qué era cada una, por qué importaba, cómo se ve el comportamiento corregido, y si tu propio despliegue necesita algún cambio de configuración.
Qué se supone que debía hacer el límite de tasa
El inicio de sesión por contraseña del dashboard vive en /auth/password-login. Está limitado por tasa según la IP del cliente, diez intentos por minuto por defecto, devolviendo HTTP 429 al superarlo. Tanto un usuario desconocido como una contraseña incorrecta devuelven el mismo 401 Invalid credentials genérico, así que el endpoint no puede usarse como oráculo de enumeración de usuarios.
El diseño está bien. El problema era de dónde salía la “IP del cliente”.
Uno: la IP del cliente era lo que el cliente dijera
client_ip() en hermes_cli/dashboard_auth/request_utils.py solía devolver el primer valor de la cabecera X-Forwarded-For de la petición. Esa cabecera es, por construcción, información que alguien más escribió en la cadena — y un cliente puede falsificarla. Así que:
- enviar un valor
X-Forwarded-Fordistinto en cada petición generaba un bucket de límite de tasa nuevo, dejando sin sentido el tope de diez por minuto; - el tope de peticiones pendientes por IP del flujo de inicio de sesión nativo podía eludirse de la misma forma;
- la dirección de origen registrada en el log de auditoría de autenticación pasaba a ser lo que al atacante le apeteciera escribir.
La corrección es contundente: client_ip() ahora solo devuelve el peer de ASGI (request.client.host) y nunca lee la cabecera en crudo. ¿Y el caso del proxy inverso? El ProxyHeadersMiddleware de Uvicorn reescribe ese peer a partir de X-Forwarded-For solo cuando la petición llega realmente desde un proxy de confianza — loopback, o una dirección listada en dashboard.trusted_proxies — y toma el salto más a la derecha que no es de confianza, de modo que un proxy inverso normal sigue dando a cada cliente real su propio bucket.
Dos: un solo campo podía llenarte el disco
POST /auth/password-login no está autenticado. Solía copiar la cadena provider de la petición tal cual en dashboard-auth.log, tanto en la ruta de proveedor desconocido (404) como en la de límite excedido (429). Una sola petición podía, por tanto, escribir una entrada de log de cualquier tamaño: la propia prueba del PR muestra un provider de 100.000 caracteres dejando unos 100 KB en el log antes del arreglo, y 336 caracteres después.
provider ahora está limitado a 128 caracteres. FastAPI rechaza los valores más largos con un 422 antes de que se ejecute el handler, así que no se registra nada. Además, audit_log() limita cada valor de cadena a 256 caracteres — incluidos los valores anidados y el repr de los que no son JSON —, de modo que un futuro campo de auditoría no pueda reintroducir la misma escritura sin límite. Hay exactamente un efecto secundario: un proveedor de contraseñas personalizado cuyo ID supere los 128 caracteres ya no podrá iniciar sesión. Los IDs de proveedor incluidos (basic, nous, self-hosted, drain-secret) son mucho más cortos.
Tres: las rutas de autenticación públicas no tenían límite de tamaño de cuerpo
Las tres rutas POST públicas bajo /auth/ — /auth/password-login, /auth/native/token, /auth/native/refresh — pasan el control de autenticación, tras lo cual FastAPI almacena en búfer todo el cuerpo JSON antes de validarlo. No había límite de tamaño, así que un cliente anónimo podía hacer que el dashboard retuviera en memoria una subida enorme o interminable.
Un nuevo AuthBodyLimitMiddleware, puramente ASGI, impone un presupuesto de 64 KiB a los POST públicos de /auth/:
- si
Content-Lengthsupera el límite, responde 413 antes de leer un solo byte del cuerpo; - también cuenta los bytes que recibe realmente, así que un cuerpo en chunks, o uno con un
Content-Lengthsubestimado, se corta en el mismo límite.
El middleware se registra como el más interno — ningún middleware HTTP externo lee cuerpos de petición —, así que el límite se aplica antes de que nada se almacene en búfer. Las rutas autenticadas, las subidas y los WebSockets no cambian.
Qué tiene que cambiar en tu propio despliegue
Las tres correcciones son comportamiento del lado del servidor; no hay código que escribir. Lo único que merece la pena revisar es dashboard.trusted_proxies: solo los peers listados ahí (más loopback, que siempre es de confianza) pueden aportar X-Forwarded-For. Las entradas sin límite como *, 0.0.0.0/0 y ::/0 se rechazan.
dashboard:
public_url: "https://dashboard.example.com/hermes"
trusted_proxies:
- "172.20.0.0/24"
Si no incluyes tu proxy inverso en la lista, los clientes que están detrás de él no pueden presentar su propia dirección, así que todos los clientes tras ese proxy comparten un único presupuesto de inicio de sesión por contraseña y los eventos de auditoría registran la dirección del proxy. Eso peca de conservador — un bucket compartido es mejor que uno falsificable —, pero vuelve impreciso el límite de tasa, así que es preferible una IP de proxy exacta, o una red acotada y exclusiva del proxy.
Hay una forma tosca pero eficaz de comprobarlo: llama al endpoint de inicio de sesión once veces y espera un 429 en la undécima. Luego hazlo otra vez con un X-Forwarded-For falsificado en cada petición; la undécima debería seguir siendo 429. Si la segunda tanda pasa sin problema, tu instancia aún tiene el comportamiento antiguo.
Estado de la versión
Las tres correcciones están en main y todavía en ninguna etiqueta publicada (la versión más reciente es v0.21.5, tag v2026.9.24). Las instalaciones vía Git las obtienen al hacer pull de la última main; las imágenes Docker y los despliegues alojados las reciben cuando se compile la próxima etiqueta. Puedes confirmar qué estás ejecutando con hermes version, y el panorama completo de la configuración del inicio de sesión del dashboard y del proxy está en la referencia del comando dashboard.
El patrón detrás de las tres
Estas tres correcciones comparten una misma forma: entrada no autenticada tratada como si fuera fiable. Una dirección de origen declarada por el cliente tratada como identidad, una cadena enviada por el cliente tratada como un campo acotado, un cuerpo enviado por el cliente tratado como de tamaño razonable. La misma clase aparece en todo servicio expuesto a internet, y el arreglo es siempre el mismo: confía solo en lo que observó tu propio lado, y pon un techo a todo lo que llega.
Para completar, ese mismo día también trajo correcciones sobre credenciales y límites de ficheros — la exportación de perfiles que podía llevarse tus secretos y el límite de acceso entre perfiles merecen leerse junto a estas.