127.0.0.1 n'est pas une frontière de sécurité : Hermes apprend désormais à son port A2A à refuser les navigateurs


Vous avez suivi la documentation, activé A2A, l’avez lié à 127.0.0.1 sur le port 9900 et avez sauté le token — parce que « seule cette machine peut l’atteindre » sonne déjà comme une garantie. Le lendemain, vous ouvrez une page inconnue dans votre navigateur et un script qu’elle contient envoie une requête à http://127.0.0.1:9900/. Vous supposez que le navigateur va la bloquer. Il ne le fera pas : du point de vue du navigateur, cette page et cette requête sont deux ports de la même machine, et la requête a parfaitement le droit de sortir.

La question n’a jamais été « qui peut se connecter à ce port ». Elle était « qui peut faire envoyer cette requête par votre navigateur à sa place ». Une PR fusionnée le 27 octobre (#145310) ferme cette brèche : en mode sans token, le listener A2A refuse désormais les POST venus d’un navigateur et valide l’en-tête Host sur chaque route.

Ce que le mode par défaut fait réellement confiance

La plateforme A2A de Hermes fonctionne dans les deux sens : votre agent peut appeler d’autres agents A2A (un ensemble d’outils sortants, désactivé par défaut), et d’autres agents peuvent envoyer des tâches à votre Hermes (le listener entrant). Le côté entrant se configure ainsi :

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

Sans A2A_BEARER_TOKEN ni A2A_PEER_TOKENS, la règle est « pas de token ⇒ localhost uniquement » : le serveur se lie à 127.0.0.1, et élargir la liaison exige un token et un A2A_HOST explicite. Dans un monde de processus CLI et d’agents, ce raisonnement tient — faire confiance aux autres processus de sa propre machine est raisonnable.

Il oubliait une catégorie de processus : le navigateur. Un navigateur n’est pas « un programme local auquel vous faites confiance ». C’est un bac à sable qui charge tout l’internet, et n’importe quelle page qu’il affiche peut poster vers 127.0.0.1:9900 tant que la requête est une « requête simple » — pas de préflight, pas de demande de permission.

Première porte : refuser les POST venus d’un navigateur

Le contrôle se trouve dans plugins/platforms/a2a/security.py, sous un nom qui dit ce qu’il fait : browser_post_refusal(). En mode sans token, trois formes de requête reçoivent un 403 :

  • un en-tête Origin est présent — les vrais clients agents et les SDK ne l’envoient pas, les navigateurs toujours ;
  • Sec-Fetch-Site vaut autre chose que none ou same-origin — le navigateur étiquette le site d’où vient la requête ;
  • Content-Type n’est pas application/json (ou un type +json) — une page ne peut éviter le préflight qu’avec une poignée de types de média simples, et A2A parle JSON-RPC, donc du JSON par définition.

La troisième est la coupe la plus habile, parce qu’elle ne dépend pas de l’honnêteté du navigateur sur l’origine de la requête. Elle exige que la requête ressemble à du A2A. Une page qui veut envoyer du JSON doit déclencher un préflight, et ce préflight est refusé. Le client A2A de Hermes envoie déjà application/json et aucun de ces en-têtes de navigateur : les appels agent à agent ne sont pas touchés.

Deuxième porte : le Host doit être un nom de loopback

La seconde porte pare une attaque différente : le DNS rebinding. En clair : un attaquant contrôle un nom d’hôte, le fait d’abord résoudre vers son propre serveur pour que vous chargiez une page depuis chez lui, puis fixe un TTL DNS très court. Un peu plus tard, la page redemande le même nom d’hôte — et cette fois il résout vers 127.0.0.1. Le navigateur considère toujours qu’il s’agit du même site, la politique de même origine ne vous protège donc plus, et le script peut lire tout ce que votre service local renvoie.

Le correctif vérifie Host dans A2ARequestHandler.parse_request(), avant l’exécution de toute route : seuls les littéraux de loopback (localhost, 127.x.x.x, [::1], localhost.) et l’hôte de A2A_PUBLIC_URL sont acceptés. Tout le reste reçoit un 403 — pas même /health ni l’Agent Card.

Pourquoi autoriser A2A_PUBLIC_URL ? Parce que les déploiements réels se trouvent souvent derrière un reverse proxy qui transmet le nom d’hôte qu’il a reçu. A2A_PUBLIC_URL, c’est vous qui déclarez « ce service s’appelle ainsi vu de l’extérieur » : elle est donc de confiance — et la documentation ajoute la mise en garde évidente : ne la renseignez qu’avec un nom que vous contrôlez.

Les listeners authentifiés par token ne sont volontairement pas concernés : dès qu’un token existe, c’est lui qui décide qui entre. C’est une répartition des rôles, pas un oubli.

Vérifier votre propre instance

Une fois sur un build qui contient le changement, essayez trois choses depuis une autre machine ou un autre processus. D’abord, une récupération normale de l’Agent Card doit réussir :

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

Ensuite, un Host étranger forgé doit renvoyer 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

Enfin, un POST portant Origin doit lui aussi renvoyer 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 console du navigateur affiche une erreur CORS au lieu d’un 403, c’est que le navigateur l’a bloquée en premier — très bien aussi, simplement une autre couche.

Trois erreurs fréquentes

Un : pas de token ne veut pas dire pas de modèle de permissions. Outre ces deux portes, A2A embarque déjà une liste d’autorisation A2A_TRUSTED_PEERS, une limitation de débit par identité (A2A_RATE_LIMIT, 60 requêtes par minute et par identité par défaut), un filtrage des injections de prompt sur le texte entrant, une rédaction des identifiants dans les réponses sortantes, et un journal d’audit dans ~/.hermes/a2a_audit.jsonl. Un détail à connaître : liez-vous à une adresse non-loopback sans définir A2A_TRUSTED_PEERS et chaque dispatch est refusé avec un 403. C’est un comportement fail-closed, pas un bug.

Deux : pour le travail entre machines, utilisez des tokens. Faites les deux, dans cet ordre : définissez A2A_PEER_TOKENS (alice:tok1,bob:tok2, un identifiant par pair — bien plus simple à déboguer qu’un secret partagé), et seulement ensuite A2A_HOST. L’adresse de liaison n’est pas une permission ; le token l’est. Le même raisonnement apparaît dans le durcissement de la connexion au dashboard.

Trois : n’improvisez pas l’URL publique. Un A2A_PUBLIC_URL erroné annonce une carte pointant vers une adresse que les pairs ne peuvent pas atteindre, et le symptôme est « l’autre côté expire en boucle », pas un message d’erreur. La surface complète de commandes et de configuration se trouve dans la référence de la commande gateway ; si c’est votre première installation, parcourez le guide d’installation avant la section A2A.

Peut-on déjà l’utiliser

Le changement a été fusionné sur main le 27 octobre. La v0.22.5 est sortie le 23 octobre, donc même les tags canary de ce jour-là ne le contiennent pas — vous pouvez vérifier la chronologie sur la page de version v0.22.5. Attendez la prochaine version, ou suivez main.

Une règle empirique à garder, plus utile que n’importe quel extrait de configuration : si un port est joignable depuis une page web de votre navigateur, il n’est pas « local uniquement » — il est joignable par tout l’internet, à travers votre navigateur. Concevez la sécurité de vos services locaux à partir de ce postulat.