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 de none ou same-origin — o navegador rotula de qual site a requisição veio;
  • o Content-Type nã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.