Hermes ne récupère plus n'importe quelle URL qu'on lui tend

Vous faites tourner un service de génération d’images sur le petit serveur dans votre couloir et vous pointez le base_url d’un provider dessus. Un jour, l’agent génère une image, et l’« URL d’image » qu’il reçoit en retour est http://127.0.0.1:8080/admin/... — et Hermes la récupère docilement et la met en cache en tant qu’image. Autre scénario, pire encore : l’URL de résultat pointe vers 169.254.169.254, le service de métadonnées d’instance du cloud, et les identifiants IAM temporaires de la machine se promènent sur un chemin qui ressemble exactement à un téléchargement d’image.
Ces URL partagent une propriété : vous ne les avez pas choisies. Elles arrivent dans la réponse d’un provider, dans un champ que le modèle a rempli lui-même, ou depuis une entrée d’un index tiers — la surface d’attaque canonique. La PR #114887, fusionnée le 18 septembre 2026, branche sept de ces points de récupération sur le garde-fou SSRF que le dépôt possédait déjà.
La forme du problème : sept points de récupération utilisant encore des clients bruts
Hermes disposait déjà de contrôles de sûreté des URL (tools/url_safety.py), utilisés par les téléchargements de médias des plateformes, les outils web et les chemins du navigateur. Mais sept autres endroits appelaient directement requests.get, httpx.get ou urllib.request.urlopen, sur des URL dont ils n’avaient pas la maîtrise :
agent/provider_media.py::save_url— l’URL de résultat renvoyée par un provider de génération, partagée parsave_url_imageetsave_url_video;- les deux points de récupération internes aux plugins de génération d’images OpenAI et OpenAI-Codex, qui transforment une image distante en octets ou en data URL ;
_http_get/_downloaddu pet store et la récupération du manifest petdex ;- le chemin image distante → data URL du gateway TUI ;
- la récupération du catalogue sitemap de skills.sh.
Une porte avait une serrure. Les sept fenêtres à côté n’en avaient pas — soit le mode de défaillance le plus courant et le plus instructif qui existe en sécurité.
Le correctif : trois contrôles, chacun plus difficile à contourner que le précédent
Rien de nouveau n’a été inventé. Le garde-fou existant a été appliqué intégralement, et les trois contournements faciles ont été fermés :
- Contrôle avant la requête. Chaque point exécute d’abord
is_safe_url; une adresse refusée n’ouvre jamais de connexion, et l’erreur dit ce qui s’est passé :Blocked request to private/internal address: 127.0.0.1. - Nouveau contrôle au moment de la connexion. Les clients sont construits avec
create_ssrf_safe_client, qui réapplique la politique à la connexion TCP et se connecte à l’IP validée tout en préservant Host/SNI. Cela ferme la fenêtre de DNS rebinding (TOCTOU) : si la première résolution renvoie une adresse publique et la seconde une adresse interne, une implémentation qui ne contrôle qu’avant la requête tombe droit dedans. - Une boucle de redirection manuelle et bornée. Cinq sauts au maximum, chacun revalidé — et un 3xx sans en-tête
Locationéchoue désormais en mode fermé au lieu d’être mis en cache comme une image. Les en-têtes fournis par l’appelant (par exemple la clé bearer vidéo d’OpenRouter) sont limités au premier saut uniquement : une redirection vers un autre domaine ne peut donc pas les emporter avec elle.
Les classes d’adresses que le garde-fou refuse (d’après la documentation officielle) :
- Réseaux privés (RFC 1918) :
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 - Boucle locale :
127.0.0.0/8,::1 - Link-local :
169.254.0.0/16, qui inclut les métadonnées cloud en169.254.169.254 - CGNAT / espace d’adressage partagé (RFC 6598) :
100.64.0.0/10— ce que distribuent les VPN Tailscale et WireGuard - Noms d’hôte des métadonnées cloud :
metadata.google.internal,metadata.goog - Adresses réservées, multicast et non spécifiées
Deux réglages par défaut donnent le ton : un échec DNS compte comme un blocage (mode fermé), et chaque saut d’une chaîne de redirections est revalidé.
Si vous avez vraiment besoin d’URL internes, deux options existent
Il ne s’agit pas d’une interdiction générale, et la documentation trace la limite avec soin :
# Désactivation globale (par défaut : false) — une décision explicite de frontière de confiance
security:
allow_private_urls: true
# Plus étroit : uniquement le bloc fake-ip du proxy local lui-même
security:
fake_ip_ranges:
- 198.18.0.0/15
allow_private_urlsconvient aux réseaux domestiques qui résolventhome.arpadans l’espace RFC 1918, aux endpoints Ollama ou llama.cpp limités au LAN, aux wikis internes. La documentation est directe sur ce que cela signifie : c’est une frontière de confiance assumée — ne l’activez que là où « l’agent de cette machine peut être détourné par injection de prompt pour frapper le réseau local » est un risque acceptable, et laissez-la désactivée sur les gateway exposés publiquement. Un détail à retenir : le contrôle de sous-chaîne d’hôte qui bloque les domaines Unicode sosies reste actif quoi qu’il arrive, quel que soit ce réglage.fake_ip_rangesconcerne les proxies TUN en mode fake-ip (Mihomo/Clash fake-ip, Surge enhanced). Ces proxies répondent à tout nom hors de leur filtre par une adresse issue de leur propre bloc (198.18.0.0/15par défaut) : sans cela, le garde-fou d’adresses privées rejetterait toutes les requêtes sortantes avec « URL targets a private or internal network address », alors même que la requête n’atteint jamais le réseau. Déclarer le bloc laisse passer uniquement ce bloc ; les destinations loopback, RFC 1918, link-local, CGNAT et métadonnées restent bloquées. Une entrée qui chevauche l’une de ces classes —0.0.0.0/0ou::/0— est ignorée avec un avertissement plutôt que d’élargir discrètement le garde-fou.
Une dernière exception compte dans l’usage quotidien : le base_url de votre propre provider n’est pas « l’URL de quelqu’un d’autre ». Un téléchargement effectué directement depuis le base_url configuré (l’endpoint de contenu vidéo d’OpenRouter en est l’exemple) saute le contrôle de classe d’adresse privée sur ce premier saut, mais le plancher lié aux métadonnées cloud s’applique toujours, et toute redirection qu’il émet est revalidée intégralement. Donc si vous hébergez un provider de génération d’images sur votre LAN et voulez que ses URL de résultat soient mises en cache localement, c’est exactement le cas pour lequel security.allow_private_urls: true existe.
Ce que la PR a mesuré : neuf sites sur neuf touchés avant, zéro après
La vérification mérite d’être citée, car c’est le genre qui prouve réellement quelque chose. Un listener en boucle locale enregistre chaque connexion, et le vrai chemin de code pilote chaque point d’appel :
| Scénario | Avant | Après |
|---|---|---|
http://127.0.0.1:PORT/... sur les neuf sites |
le listener enregistrait une connexion sur chacun des sites ; les corps étaient renvoyés et mis en cache | zéro connexion ; chaque site levait son propre type d’erreur (_image_to_data_url renvoie None) avec Blocked request to private/internal address: 127.0.0.1 |
un premier saut sûr, puis un 302 vers 169.254.169.254 |
une vraie tentative de connexion | ce saut est refusé |
un 3xx sans en-tête Location |
le corps du 3xx était mis en cache comme une image | échec en mode fermé |
Ce qu’il faut faire sur votre propre installation
Trois étapes concrètes, de la plus importante à la moins urgente :
- Sur un gateway exposé publiquement, vérifiez que la dérogation est désactivée. Lancez
hermes config get security.allow_private_urls; une valeur non définie signifie la valeur par défaut, qui est désactivée. La laisser active après une expérimentation ponctuelle avec un service interne est une erreur fréquente et très discrète. - Si vous faites tourner de la génération d’images ou un modèle local sur votre LAN, résistez à l’envie de basculer d’abord l’interrupteur global. Si tout ce qu’il vous faut est que les URL de résultat d’un provider soient mises en cache localement, c’est ce réglage précis qu’il faut viser. Et si vous êtes derrière un proxy TUN en fake-ip, préférez le
fake_ip_ranges, plus étroit. - Ajoutez du bon sens sur les machines cloud. Ce garde-fou est une porte infranchissable, mais les identifiants des métadonnées d’instance sont aussi accessibles via des commandes en terminal — et ce versant-là est géré par une porte d’approbation, que nous détaillons dans l’article sur la porte d’approbation 169.254.169.254. Les deux ensemble donnent le tableau complet.
Pour aller plus loin : le correctif de schéma fermé sur les identifiants du catalogue MCP (une entrée tierce ne doit pas pouvoir écrire des variables d’environnement arbitraires) et le modèle de revue du catalogue MCP distant officiel. La documentation de sécurité elle-même se trouve à hermes-agent.nousresearch.com/docs/user-guide/security.
État des versions publiées
La PR #114887 a été fusionnée dans main le 18 septembre 2026, et le tag le plus récent reste v0.21.3 (nom de tag v2026.9.14, publié le 14 septembre) : elle n’est donc pas encore dans une version taguée. Une installation via git la récupérera à la prochaine mise à jour :
hermes --version
hermes update
Les utilisateurs de l’image Docker et du cloud l’auront avec le prochain tag.
La leçon
En résumé, la règle est la suivante : un garde-fou ne vaut que par le nombre d’entrées qu’il couvre, pas par la sévérité de sa rédaction. Sept points de récupération utilisaient encore des clients HTTP bruts, et au moins deux classes d’attaque — le DNS rebinding et un 3xx sans Location — mettent d’emblée en échec le schéma « contrôle avant la requête ». La forme robuste fait les trois choses à la fois : valider à l’entrée, revalider à la connexion, valider chaque saut — et offrir à ceux qui ont légitimement besoin d’accès internes un interrupteur explicite, auditable et aussi étroit que possible.