Hermes ya no descarga cualquier URL que alguien le ponga delante


Tienes un servicio de generación de imágenes en el servidor pequeño del pasillo y apuntas el base_url de un proveedor hacia él. Un día el agente genera una imagen y la «URL de la imagen» que recibe dice http://127.0.0.1:8080/admin/..., y Hermes la descarga obedientemente y la cachea como imagen. O toma el guion peor: la URL del resultado apunta a 169.254.169.254, el servicio de metadata de instancia de la nube, y las credenciales IAM temporales de la máquina se van de paseo por un camino que se parece exactamente a descargar una imagen.

Estas URLs comparten una propiedad: no las elegiste tú. Llegan de la respuesta de un proveedor, de un campo que el modelo rellenó por su cuenta o de una entrada de un índice de terceros: la superficie de ataque canónica. El PR #114887, fusionado el 18 de septiembre de 2026, conecta siete puntos de descarga de este tipo con el SSRF guard que el repositorio ya tenía.

La forma del problema: siete puntos de descarga que seguían usando clientes en crudo

Hermes ya tenía sus comprobaciones de seguridad de URL (tools/url_safety.py), que usaban las descargas de media de las plataformas, las tools web y los caminos del navegador. Pero otros siete sitios llamaban directamente a requests.get, httpx.get o urllib.request.urlopen, sobre URLs en las que no tenían ninguna voz ni voto:

  • agent/provider_media.py::save_url: la URL de resultado que devuelve un proveedor de generación, compartida por save_url_image y save_url_video;
  • los dos puntos de descarga dentro de los plugins de generación de imágenes de OpenAI y OpenAI-Codex que convierten una imagen remota en bytes o en una data URL;
  • el _http_get / _download de la pet store y la descarga del manifest de petdex;
  • el camino de imagen remota a data URL del gateway de la TUI;
  • la descarga del catalog del sitemap de skills.sh.

Una puerta tenía cerradura. Siete ventanas a su lado no: que es el modo de fallo más común y más instructivo en el trabajo de seguridad.

El arreglo: tres comprobaciones, cada una más difícil de sortear que la anterior

No se inventó nada nuevo. El guard existente se aplicó por completo y se cerraron los tres bypass fáciles:

  1. Comprobar antes del fetch. Todos los sitios ejecutan primero is_safe_url; una dirección rechazada nunca abre conexión, y el error dice qué ha pasado: Blocked request to private/internal address: 127.0.0.1.
  2. Volver a comprobar en el momento de conectar. Los clientes se construyen con create_ssrf_safe_client, que vuelve a aplicar la política en el connect TCP y marca la IP ya validada conservando el Host/SNI. Esto cierra la ventana de DNS rebinding (TOCTOU): si la primera resolución devuelve una dirección pública y la segunda devuelve una interna, una implementación que solo comprueba antes del fetch entra de lleno en ella.
  3. Un bucle de redirecciones manual y acotado. Como máximo cinco saltos, cada uno revalidado, y un 3xx sin cabecera Location ahora falla en cerrado en lugar de cachearse como imagen. Las cabeceras que aporta quien llama (por ejemplo, la bearer key de vídeo de OpenRouter) se limitan al primer salto, así que una redirección a otro dominio no puede llevárselas consigo.

Las clases de dirección que rechaza el guard (según la documentación oficial):

  • Redes privadas (RFC 1918): 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
  • Loopback: 127.0.0.0/8, ::1
  • Link-local: 169.254.0.0/16, que incluye la metadata de la nube en 169.254.169.254
  • CGNAT / espacio de direcciones compartido (RFC 6598): 100.64.0.0/10, lo que reparten las VPN de Tailscale y WireGuard
  • Nombres de host de metadata de la nube: metadata.google.internal, metadata.goog
  • Direcciones reservadas, de multicast y sin especificar

Dos valores por defecto marcan el tono: los fallos de DNS cuentan como bloqueados (fail closed) y todos los saltos de una cadena de redirecciones se revalidan.

Si de verdad necesitas URLs internas, hay dos opt-in

Esto no es una prohibición en bloque, y la documentación traza la línea con cuidado:

# Opt-out global (por defecto: false) — una decisión explícita de frontera de confianza
security:
  allow_private_urls: true

# Más acotado: solo el bloque fake-ip del propio proxy local
security:
  fake_ip_ranges:
    - 198.18.0.0/15
  • allow_private_urls encaja en redes domésticas que resuelven home.arpa dentro de espacio RFC 1918, en endpoints de Ollama o llama.cpp solo de LAN y en wikis internas. La documentación es tajante sobre lo que significa: esto es una frontera de confianza deliberada; actívalo solo donde «el agente de esta máquina puede ser objeto de prompt injection para atacar la red local» sea un riesgo aceptable, y déjalo desactivado en gateways expuestos a internet. Un detalle que merece la pena anotar: el guard de subcadenas de host que bloquea dominios Unicode que se parecen al original se mantiene activo en cualquier caso, con independencia de este ajuste.
  • fake_ip_ranges es para proxies TUN en modo fake-ip (Mihomo/Clash fake-ip, Surge enhanced). Esos proxies responden a cualquier nombre fuera de su filtro con una dirección de su propio bloque (198.18.0.0/15 por defecto), así que, de lo contrario, el guard de direcciones privadas rechazaría todas las descargas salientes con «URL targets a private or internal network address» mientras la petición nunca llega a la red. Declarar el bloque deja pasar solo ese bloque; los destinos de loopback, RFC 1918, link-local, CGNAT y metadata siguen bloqueados. Una entrada que solape cualquiera de esas clases —0.0.0.0/0 o ::/0— se ignora con un aviso en lugar de ampliar el guard en silencio.

Hay una excepción más que importa en el uso diario: tu propio base_url de proveedor no es «la URL de otro». Una descarga que se hace directamente desde el base_url configurado (el endpoint de contenido de vídeo de OpenRouter es el ejemplo) se salta la comprobación de clase de dirección privada en ese primer salto, pero el suelo de metadata de la nube sigue aplicándose, y cualquier redirección que emita se revalida por completo. Así que si alojas un proveedor de generación de imágenes en tu LAN y quieres que sus URLs de resultado se cacheen en local, ese es el caso para el que existe security.allow_private_urls: true.

Lo que midió el PR: nueve de nueve acertaban antes, cero después

La verificación merece que se cite, porque es del tipo que demuestra algo de verdad. Un listener en loopback registra cada conexión, y el camino de código real ejercita cada punto de llamada:

Escenario Antes Después
http://127.0.0.1:PORT/... en los nueve sitios el listener registraba un impacto en todos los sitios; los cuerpos se devolvían y se cacheaban cero impactos; cada sitio lanzaba su propio tipo de error (_image_to_data_url devuelve None) con Blocked request to private/internal address: 127.0.0.1
un primer salto seguro y luego un 302 a 169.254.169.254 un intento de conexión real ese salto se rechaza
un 3xx sin cabecera Location el cuerpo del 3xx se cacheaba como imagen falla en cerrado

Qué hacer en tu propia instalación

Tres pasos prácticos, el más importante primero:

  1. En un gateway expuesto a internet, confirma que el opt-out está desactivado. Ejecuta hermes config get security.allow_private_urls; sin configurar significa el valor por defecto, que está desactivado. Dejarlo activado después de un experimento puntual con un servicio interno es un error habitual y muy silencioso.
  2. Si ejecutas generación de imágenes o un modelo local en tu LAN, resístete a tocar primero el interruptor global. Si lo único que necesitas es que se cacheen en local las URLs de resultado de un proveedor, ese es el mando que buscas. Y si estás detrás de un proxy TUN fake-ip, prefiere el fake_ip_ranges más acotado.
  3. Añade sentido común en las máquinas de la nube. Este guard es una puerta por la que no se puede rodear, pero a las credenciales de metadata de instancia también se puede llegar con comandos de terminal, y ese lado lo cubre un gate de aprobación, que tratamos en el artículo sobre el gate de aprobación de 169.254.169.254. Los dos juntos son la imagen completa.

Lectura relacionada: el arreglo de esquema cerrado de credenciales del catalog MCP (las entradas de terceros no deben poder escribir variables de entorno arbitrarias) y el modelo de revisión de el catalog MCP remoto oficial. La documentación de seguridad está en hermes-agent.nousresearch.com/docs/user-guide/security.

Estado de la release

El PR #114887 se fusionó en main el 18 de septiembre de 2026, y el tag más reciente sigue siendo la v0.21.3 (nombre del tag v2026.9.14, publicado el 14 de septiembre), así que no está todavía en ninguna release etiquetada. Una instalación git lo recoge en la próxima actualización:

hermes --version
hermes update

Los usuarios de imagen Docker y de la nube lo reciben con el próximo tag.

La lección

Resumiéndolo, la regla es esta: un guard vale tanto como el número de entradas que cubre, no por lo estricto que esté escrito. Siete puntos de descarga seguían usando clientes HTTP en crudo, y al menos dos clases de ataque —el DNS rebinding y un 3xx sin Location— derrotan directamente el patrón de comprobar antes del fetch. La forma robusta hace las tres cosas a la vez: validar en la entrada, validar otra vez al conectar, validar cada salto, y dar a quien legítimamente necesita acceso interno un interruptor explícito, auditable y lo más acotado posible.