Una URL, dos hosts: cómo se tapó la grieta entre parsers en el guard SSRF de Hermes


Alguien te manda un enlace por DM y se lo pasas a tu agente: descarga esto, échale un ojo, dime si es importante. Funciona, llega el resumen, nada parece raro. Detrás de ese éxito anodino, dos trozos de código no se pusieron de acuerdo sobre a qué apuntaba el enlace: la comprobación que decide “esta dirección está permitida” y el cliente que realmente abrió el socket. La comprobación creía que era una IP pública. El navegador leía los mismos caracteres como un puerto de tu propia máquina. El PR #145578, fusionado el 27 de octubre, cierra esa grieta: cuando una URL admite dos lecturas plausibles, Hermes ahora la rechaza en vez de elegir una.

La comprobación y la conexión leían cadenas distintas

Antes de descargar nada, Hermes filtra el destino: loopback, rangos privados y endpoints de metadatos de la nube se supone que están fuera de límites. Esa lógica vive en tools/url_safety.py, con is_safe_url() como responsable principal de la decisión y is_always_blocked_url() como respaldo más contundente.

El código de filtrado parsea la URL con urllib.parse de Python, en concreto con urlsplit. Lo que acaba abriendo la conexión, a menudo, no. La herramienta de navegador se apoya en Chromium. Los backends de fetch basados en Node (un Firecrawl autoalojado, por ejemplo) y urllib3 siguen cada uno sus propias convenciones. Los navegadores siguen el estándar WHATWG URL. Dale la misma cadena a ambas pilas y puedes obtener dos hosts distintos, y el host es lo único que mira la comprobación de seguridad.

Cuatro URL, dos respuestas

El PR incluye cuatro casos que puedes probar directamente:

URL Host según urlsplit / httpx Host según WHATWG Resultado en main
http://127.0.0.1:8080\@1.1.1.1 1.1.1.1 127.0.0.1:8080 is_safe_url devuelve True
http://%31%32%37.0.0.1/ %31%32%37.0.0.1 127.0.0.1 depende del DNS (True tras un proxy cuando el DNS falla)
http://***@1.1.1.1/ 1.1.1.1 169.254.169.254 is_always_blocked_url devuelve False
http:\\169.254.169.254\ ninguno 169.254.169.254 is_always_blocked_url devuelve False

La primera fila es la ilustración más clara. urlsplit ve la @, decide que todo lo anterior es un par de credenciales y reporta el host como 1.1.1.1: público, correcto, adelante. Chromium termina la authority en la barra invertida y se conecta tan feliz a 127.0.0.1:8080. Una dirección que pasó el filtro como “de internet” acaba en un puerto de loopback.

La segunda fila es un host codificado en porcentaje. La comprobación ve el texto literal %31%32%37.0.0.1; el navegador lo decodifica a 127.0.0.1. En main el veredicto incluso depende del DNS: con un proxy configurado y la resolución fallando, la función devuelve True.

Las filas tres y cuatro van por otro camino. is_always_blocked_url() no bloqueaba ninguna de las dos, y esa es precisamente la función en la que confían los caminos que se saltan is_safe_url(): el backend local de Chromium y el sidecar híbrido. La última línea de defensa para las direcciones de metadatos tenía el mismo agujero.

Dónde se separan las dos lecturas

WHATWG hace tres cosas que urlsplit no hace:

  • La barra invertida es una barra normal. Para esquemas especiales como http, una barra invertida equivale a /, y la authority termina en la primera. Así que la authority de http://127.0.0.1:8080\@1.1.1.1 es solo 127.0.0.1:8080.
  • El host se decodifica en porcentaje. %31%32%37 es 127.
  • Las barras que faltan se arreglan. http:\\host y http:///host se normalizan ambos a http://host.

urlsplit no hace nada de eso. No trata la barra invertida de forma especial, no decodifica el host y entiende un // ausente como “aquí no hay ninguna authority”. Lo que significa que “¿esta URL apunta dentro de mi red?” no tiene una única respuesta: depende a quién le preguntes. La comprobación de seguridad preguntó a una pila; el cliente usó otra. El espacio entre ambas es el bypass.

El arreglo: rechazar la ambigüedad en vez de resolverla

En lugar de enseñarle a un parser a imitar al otro, el parche toma el camino contrario: si dos pilas podrían leer hosts distintos de la misma cadena, se trata como insegura. El término es fail closed, es decir, rechazar una dirección que quizá habría estado bien antes que adivinar qué lectura usará el cliente.

Eso vive en un nuevo helper, _authority_ambiguity(), en la línea 253 de tools/url_safety.py. Para URL http(s) recorta los espacios en blanco ordinarios de delante y detrás (espacio, tabulador, CR, LF, VT, FF) y luego rechaza la URL si se cumple alguno de estos supuestos:

  • Aparece un carácter de control C0 o un DEL en cualquier posición, incluso al final, como en http://169.254.169.254\x01, porque urlsplit descarta en silencio los tabuladores, el CR y el LF, así que los mismos bytes pueden significar cosas distintas a cada lado.
  • Falta la authority //: http:\\host, http:///host, http:host.
  • Aparece una barra invertida en cualquier punto de la authority.
  • La authority contiene @ (userinfo, que is_safe_url() ya rechazaba de plano).
  • El host contiene % o espacios en blanco.
  • El puerto no es válido: no numérico o fuera de rango.

is_safe_url() lo llama justo después de comprobar el esquema. Cuando rechaza, el log registra solo el motivo, nunca la URL: las URL pueden llevar credenciales y un archivo de log es un sitio más donde filtrarlas.

Por qué la comprobación de respaldo se volvió más laxa y no más estricta

is_always_blocked_url() (línea 342) atiende a los backends que nunca llaman a is_safe_url(): el Chromium local y el sidecar híbrido. Solo rechaza lo que puede llevar a un cliente a alcanzar un host distinto: caracteres de control, una authority // ausente, una barra invertida en la authority. Los hosts codificados en porcentaje se juzgan ya decodificados, así que http://%31%36%39.254.169.254/ queda atrapada.

Los puertos inválidos, los espacios en el host y el userinfo no cambian el host con el que el cliente acaba hablando, así que el respaldo los ignora a propósito. El motivo es concreto: browser_exec construye sus propias URL con template literals — f"http://localhost:{port}/", "http://localhost:%d/" y un `http://localhost:${port}/api` de JS — y un respaldo estricto rompería todas ellas en el backend local del navegador. La estrictez se queda donde corresponde, en is_safe_url(); el respaldo protege la única propiedad que importa, que el host no se pueda leer de dos maneras.

Un detalle más fácil de pasar por alto: ambas funciones toman el host de la misma cadena ya recortada que usaron para la comprobación de caracteres de control, y luego comparan el host codificado en IDNA con la dirección de metadatos después de mapear U+3002, U+FF0E y U+FF61 a .. El efecto práctico es que http://169.254.169.254。/, las formas construidas con puntos de ancho completo o medio y los dígitos de ancho completo resuelven todos a 169.254.169.254 a estos efectos, incluso cuando el DNS falla y hay un proxy configurado.

Las URL que no son http(s), como about: y data:, quedan intactas con este cambio.

Dos conceptos que conviene tener claros

SSRF: server-side request forgery. Le pides a un servidor que descargue una URL en tu nombre; un atacante construye esa URL para que el servidor alcance algo que él no puede alcanzar por su cuenta: un puerto de loopback, un panel de administración interno, una API de metadatos de la nube. Tu servicio se convierte en su proxy y en su punto de apoyo dentro de la red. Lo perverso es que la petición realmente sale desde dentro, así que las reglas del perímetro no tienen nada que objetar: quien llama es, por definición, “de los nuestros”.

169.254.169.254 es la dirección link-local que los proveedores de nube exponen en cada instancia como servicio de metadatos. No solo sirve el hostname y la región: normalmente también entrega credenciales temporales a nivel de instancia. Quien logre engañar a un servidor para que pida esa dirección puede salir de allí con la identidad cloud de la máquina, y lo que esa identidad puede hacer depende del rol asociado a la instancia. Por eso se trata como una clase de dirección aparte, que nunca se permite, en vez de meterla en la comprobación genérica de rangos privados.

Junta las dos cosas y tienes este bug: una URL que convence al validador de que apunta a internet y un host que el cliente lee como loopback o como metadatos.

Qué hacer con esto

  • Mantente al día. hermes update. El cambio llegó a main el 27 de octubre, y el PR señala explícitamente que no hay issue asociada: es endurecimiento proactivo, no el parche de un exploit conocido en circulación.
  • Cuenta con la nueva semántica: si es ambiguo, se rechaza. Si tienes automatizaciones que construyen a propósito URL ingeniosas de esta forma, ahora se rechazarán. Esa es la intención y no una regresión; las URL normales no se ven afectadas.
  • El patrón se generaliza. El arreglo anterior que estableció que el loopback no es una frontera de confianza para el puerto A2A parte de la misma idea: una decisión de seguridad vale tanto como su acuerdo con el componente que ejecuta realmente la acción.
  • Si te importan las superficies por las que un agente puede salir hacia fuera, lee la guía de seguridad del catálogo MCP junto con la superficie de control de la extensión de navegador: la primera va de lo que instalas, la segunda de cuánta autoridad tiene el lado del navegador. Este bug estaba justo donde se encuentran las dos.

La lección, si quieres una sola frase: el host que lee tu validador tiene que ser el host al que marca tu cliente. Cuando eso no se puede garantizar, fallar cerrado es la única respuesta honesta.