127.0.0.1 no es una frontera de seguridad: Hermes enseña a su puerto A2A a rechazar navegadores


Seguiste la documentación, activaste A2A, lo ataste a 127.0.0.1 en el puerto 9900 y te saltaste el token, porque «solo esta máquina puede llegar» ya suena seguro. Al día siguiente abres una página que no conoces en el navegador y un script de esa página manda una petición a http://127.0.0.1:9900/. Das por hecho que el navegador lo va a impedir. No lo hará: desde el punto de vista del navegador, esa página y esa petición son dos puertos de la misma máquina, y la petición tiene todo el derecho a salir.

La pregunta nunca fue «quién puede conectarse a este puerto», sino «quién puede hacer que tu navegador envíe esta petición por él». Un PR fusionado el 27 de octubre (#145310) cierra ese agujero: en modo sin token, el listener A2A ahora rechaza los POST originados en el navegador y valida Host en todas las rutas.

En qué confía realmente el modo por defecto

La plataforma A2A de Hermes funciona en las dos direcciones: tu agente puede llamar a otros agentes A2A (un toolset de salida, desactivado por defecto) y otros agentes pueden enviar tareas a tu Hermes (el listener de entrada). El lado de entrada empieza así:

gateway:
  platforms:
    a2a:
      enabled: true
      extra:
        port: 9900

Sin A2A_BEARER_TOKEN ni A2A_PEER_TOKENS definidos, el modelo es «sin token ⇒ solo localhost»: el servidor ata 127.0.0.1, y ampliar el bind exige un token y un A2A_HOST explícito. En un mundo de procesos CLI y agentes, ese razonamiento se sostiene: confiar en otros procesos de tu propia máquina es razonable.

Se le escapó una clase de proceso: el navegador. Un navegador no es «un programa local en el que confías». Es un sandbox que carga internet entero, y cualquier página que tenga dentro puede hacer un POST a 127.0.0.1:9900 siempre que la petición sea una «simple request»: sin preflight, sin aviso de permiso.

Puerta uno: rechazar los POST originados en el navegador

La comprobación vive en plugins/platforms/a2a/security.py, bajo un nombre que dice lo que hace: browser_post_refusal(). En modo sin token, tres formas de petición reciben un 403:

  • hay cabecera Origin: los clientes y SDK de agentes reales no la envían, los navegadores siempre sí;
  • Sec-Fetch-Site es cualquier cosa menos none o same-origin: el navegador etiqueta de qué sitio viene la petición;
  • Content-Type no es application/json (ni un tipo +json): una página solo puede saltarse el preflight con un puñado de tipos simples, y A2A habla JSON-RPC, que es JSON por definición.

El tercer corte es el más ingenioso, porque no depende de que el navegador sea sincero sobre el origen de la petición. Exige que la petición parezca A2A. Una página que quiera enviar JSON tiene que disparar un preflight, y el preflight se rechaza. El propio cliente A2A de Hermes ya envía application/json y ninguna de esas cabeceras de navegador, así que las llamadas entre agentes quedan intactas.

Puerta dos: el Host tiene que ser un nombre de loopback

La segunda puerta protege de otro ataque: el DNS rebinding. En términos llanos: un atacante controla un nombre de host, primero lo resuelve a su propio servidor para que cargues una página desde allí y luego pone un TTL de DNS muy corto. Un poco después la página vuelve a pedir ese mismo nombre de host, y esta vez resuelve a 127.0.0.1. El navegador sigue considerándolo el mismo sitio, así que la política de mismo origen ya no te protege y el script puede leer lo que devuelva tu servicio local.

El arreglo comprueba Host dentro de A2ARequestHandler.parse_request(), antes de que se ejecute ninguna ruta: solo se aceptan los literales de loopback (localhost, 127.x.x.x, [::1], localhost.) y el host de A2A_PUBLIC_URL. Todo lo demás recibe un 403, ni siquiera /health o el Agent Card.

¿Por qué permitir A2A_PUBLIC_URL? Porque los despliegues reales suelen estar detrás de un proxy inverso que reenvía el nombre de host que recibió. A2A_PUBLIC_URL es tu forma de declarar «este servicio se llama así ahí fuera», de modo que se confía en él, y la documentación añade la advertencia obvia: configúralo solo con un nombre que controles tú.

Los listeners autenticados por token quedan deliberadamente al margen: en cuanto existe un token, el token decide quién entra. Eso es una división del trabajo, no un descuido.

Comprobar tu propia instancia

Cuando estés en una build que incluya el cambio, prueba tres cosas desde otra máquina o desde otro proceso. Primero, una petición normal del Agent Card debería funcionar:

curl -s http://your-host:9900/.well-known/agent-card.json | head -20

Segundo, un Host extranjero falsificado debería devolver 403:

curl -s -o /dev/null -w '%{http_code}\n' \
  -H 'Host: evil.example' \
  http://127.0.0.1:9900/.well-known/agent-card.json

Tercero, un POST con Origin también debería devolver 403:

curl -s -o /dev/null -w '%{http_code}\n' \
  -X POST http://127.0.0.1:9900/ \
  -H 'Origin: https://evil.example' \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"SendMessage","params":{}}'

Si la consola del navegador muestra un error de CORS en lugar de un 403, lo paró antes el navegador: también vale, solo que en otra capa.

Tres cosas que la gente hace mal

Una: sin token no significa sin modelo de permisos. Además de estas dos puertas, A2A ya lleva una allow-list A2A_TRUSTED_PEERS, rate limiting por identidad (A2A_RATE_LIMIT, 60 peticiones por minuto por identidad de forma predeterminada), filtrado de inyección de prompts en el texto entrante, redacción de credenciales en las respuestas salientes y un log de auditoría en ~/.hermes/a2a_audit.jsonl. Un detalle que conviene saber: ata a una dirección que no sea loopback sin definir A2A_TRUSTED_PEERS y todo despacho se rechaza con un 403. Eso es comportamiento fail-closed, no un bug.

Dos: para trabajo entre máquinas, usa tokens. Haz las dos cosas, en este orden: define A2A_PEER_TOKENS (alice:tok1,bob:tok2, una credencial por par, mucho más fácil de depurar que un secreto compartido) y solo entonces define A2A_HOST. La dirección de bind no es un permiso; el token sí. El mismo razonamiento aparece en el endurecimiento del login del dashboard.

Tres: no improvises la URL pública. Una A2A_PUBLIC_URL equivocada anuncia un card que apunta a una dirección que los pares no pueden alcanzar, y el síntoma es «el otro lado se queda en timeout», no un mensaje de error. La superficie completa de comandos y configuración está en la referencia del comando gateway; si es tu primera instalación, pasa por la guía de instalación antes de la sección de A2A.

¿Puedes usarlo ya?

El cambio se fusionó en main el 27 de octubre. La v0.22.5 salió el 23 de octubre, así que ni siquiera los tags canary de ese día lo contienen; puedes ver la cronología en la página de la release v0.22.5. Espera a la próxima versión o sigue main.

Una regla general que merece la pena conservar, más útil que cualquier fragmento de configuración: si un puerto es alcanzable desde una página web de tu navegador, no es «solo local»: es alcanzable por todo internet a través de tu navegador. Diseña la seguridad de tus servicios locales partiendo de esa premisa.