127.0.0.1 não é fronteira de segurança: o Hermes agora ensina a porta A2A a recusar navegadores

Você seguiu a documentação, ligou o A2A, amarrou em 127.0.0.1 na porta 9900 e deixou o token de lado — porque “só esta máquina alcança” já soa suficientemente seguro. No dia seguinte você abre uma página qualquer, desconhecida, no navegador, e um script nela dispara uma requisição para http://127.0.0.1:9900/. Você assume que o navegador vai barrar. Não vai: do ponto de vista do navegador, aquela página e aquela requisição são dois programas na mesma máquina, e a requisição está perfeitamente autorizada a sair.
A pergunta nunca foi “quem consegue se conectar a esta porta”. Foi “quem consegue fazer o seu navegador enviar essa requisição no lugar dele”. Um PR mergeado em 27 de outubro (#145310) fecha esse buraco: no modo sem token, o listener A2A agora recusa POSTs originados do navegador e valida o Host em todas as rotas.
O que o modo padrão confia de fato
A plataforma A2A do Hermes funciona nos dois sentidos: o seu agente pode chamar outros agentes A2A (um toolset de saída, desligado por padrão), e outros agentes podem mandar tarefas para o seu Hermes (o listener de entrada). O lado de entrada começa assim:
gateway:
platforms:
a2a:
enabled: true
extra:
port: 9900
Sem A2A_BEARER_TOKEN e sem A2A_PEER_TOKENS definidos, o modelo é “sem token ⇒ só localhost”: o servidor amarra em 127.0.0.1, e ampliar o bind exige um token e um A2A_HOST explícito. Num mundo de processos de CLI e agentes, esse raciocínio se sustenta — confiar em outros processos da sua própria máquina é razoável.
Só ficou de fora uma classe de processo: o navegador. Navegador não é “um programa local em que você confia”. Ele é uma sandbox que carrega a internet inteira, e qualquer página dentro dela consegue postar em 127.0.0.1:9900 desde que a requisição seja uma “simple request” — sem preflight, sem prompt de permissão.
Portão um: recusar POSTs vindos do navegador
A checagem mora em plugins/platforms/a2a/security.py, com um nome que diz exatamente o que faz: browser_post_refusal(). No modo sem token, três formatos de requisição recebem 403:
- existe um header
Origin— clientes e SDKs de agente de verdade não mandam esse header, navegadores sempre mandam; Sec-Fetch-Siteé qualquer coisa diferente denoneousame-origin— o navegador rotula de qual site a requisição veio;- o
Content-Typenão éapplication/json(nem um tipo+json) — uma página só consegue pular o preflight com um punhado de media types simples, e o A2A fala JSON-RPC, que é JSON por definição.
O terceiro corte é o mais esperto, porque não depende de o navegador ser honesto sobre a origem da requisição. Ele exige que a requisição pareça A2A. Uma página que queira mandar JSON tem que disparar um preflight, e o preflight é recusado. O próprio cliente A2A do Hermes já envia application/json e nenhum desses headers de navegador, então chamadas agente-para-agente seguem intocadas.
Portão dois: o Host precisa ser um nome de loopback
O segundo portão protege contra outro ataque: DNS rebinding. Em termos simples: o atacante controla um hostname, primeiro resolve ele para o próprio servidor dele para que você carregue uma página de lá, e depois configura um TTL de DNS bem curto. Um pouco depois a página requisita o mesmo hostname de novo — e desta vez ele resolve para 127.0.0.1. O navegador continua considerando aquilo o mesmo site, então a same-origin policy deixa de te proteger, e o script consegue ler qualquer coisa que o seu serviço local devolva.
A correção checa o Host dentro de A2ARequestHandler.parse_request(), antes de qualquer rota rodar: só literais de loopback (localhost, 127.x.x.x, [::1], localhost.) e o host de A2A_PUBLIC_URL são aceitos. Todo o resto leva 403 — nem /health, nem o Agent Card escapam.
Por que abrir exceção para A2A_PUBLIC_URL? Porque deployments reais costumam ficar atrás de um reverse proxy que repassa o hostname que recebeu. A2A_PUBLIC_URL é você declarando “lá fora este serviço se chama assim”, então ele é confiável — e a documentação acrescenta a ressalva óbvia: só aponte para um nome que você controla.
Listeners autenticados por token ficam deliberadamente intocados: a partir do momento em que existe token, é o token que decide quem entra. Isso é divisão de trabalho, não descuido.
Conferindo a sua própria instância
Quando você estiver num build que já contém a mudança, teste três coisas a partir de outra máquina ou de outro processo. Primeiro, um fetch normal do Agent Card deve passar:
curl -s http://your-host:9900/.well-known/agent-card.json | head -20
Segundo, um Host estrangeiro forjado deve 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
Terceiro, um POST carregando Origin também deve 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":{}}'
Se o console do navegador mostrar um erro de CORS em vez de 403, o navegador barrou antes — também está tudo bem, é só outra camada.
Três coisas que as pessoas erram
Um: sem token não quer dizer sem modelo de permissão. Além destes dois portões, o A2A já traz uma allow-list A2A_TRUSTED_PEERS, rate limiting por identidade (A2A_RATE_LIMIT, 60 requisições por minuto por identidade por padrão), filtragem de prompt injection no texto de entrada, redação de credenciais nas respostas de saída e um log de auditoria em ~/.hermes/a2a_audit.jsonl. Um detalhe que vale saber: amarre em um endereço que não seja loopback sem definir A2A_TRUSTED_PEERS e todo dispatch é recusado com 403. Isso é comportamento fail-closed, não bug.
Dois: para trabalho entre máquinas, use tokens. Faça as duas coisas, nesta ordem: defina A2A_PEER_TOKENS (alice:tok1,bob:tok2, uma credencial por peer — muito mais fácil de depurar do que um segredo compartilhado) e só então defina A2A_HOST. O endereço de bind não é uma permissão; o token é. O mesmo raciocínio aparece no trabalho de endurecimento do login do dashboard.
Três: não improvise a URL pública. Um A2A_PUBLIC_URL errado anuncia um card apontando para um endereço que os peers não alcançam, e o sintoma é “o outro lado vive dando timeout”, não uma mensagem de erro. A superfície completa de comandos e config está na referência do comando gateway; se esta é a sua primeira instalação, passe pelo guia de instalação antes da seção de A2A.
Já dá para usar?
A mudança entrou na main em 27 de outubro. A v0.22.5 saiu em 23 de outubro, então nem as tags canary daquele dia a contêm — você pode conferir a linha do tempo na página de release da v0.22.5. Espere o próximo release ou acompanhe a main.
Uma regra de bolso para guardar, mais útil do que qualquer trecho de config: se uma porta é alcançável a partir de uma página web no seu navegador, ela não é “só local” — ela é alcançável pela internet inteira, através do seu navegador. Projete a segurança dos seus serviços locais a partir dessa premissa.