Hermes lädt nicht mehr jede URL, die ihm jemand hinhält


Du betreibst einen Bildgenerierungsdienst auf dem kleinen Server im Flur und richtest die base_url eines Providers darauf. Eines Tages generiert der Agent ein Bild, und die „Bild-URL“, die zurückkommt, lautet http://127.0.0.1:8080/admin/... — und Hermes lädt sie pflichtbewusst und legt sie als Bild in den Cache. Oder das unschönere Drehbuch: Die Ergebnis-URL zeigt auf 169.254.169.254, den Instance-Metadata-Dienst der Cloud, und die temporären IAM-Credentials der Maschine gehen spazieren — auf einem Weg, der genau wie das Herunterladen eines Bildes aussieht.

Diese URLs haben eine Eigenschaft gemeinsam: Du hast sie nicht ausgewählt. Sie kommen aus der Antwort eines Providers, aus einem Feld, das das Modell selbst gefüllt hat, oder aus einem Eintrag in einem Index Dritter — die klassische Angriffsfläche. PR #114887, gemergt am 18. September 2026, hängt sieben solcher Fetch-Stellen an den SSRF-Guard, den das Repository bereits hatte.

Die Form des Problems: sieben Fetch-Stellen mit rohen HTTP-Clients

Hermes hatte bereits URL-Sicherheitsprüfungen (tools/url_safety.py), genutzt von Plattform-Media-Downloads, den Web-Tools und den Browser-Pfaden. Sieben andere Stellen riefen jedoch direkt requests.get, httpx.get oder urllib.request.urlopen auf — auf URLs, über die sie nicht mitbestimmen konnten:

  • agent/provider_media.py::save_url — die Ergebnis-URL, die ein Generierungs-Provider zurückgibt, gemeinsam genutzt von save_url_image und save_url_video;
  • die beiden Fetch-Stellen in den Bildgenerierungs-Plugins für OpenAI und OpenAI-Codex, die ein entferntes Bild in Bytes oder eine Data-URL umwandeln;
  • _http_get / _download des Petstores und das Laden des petdex-Manifests;
  • den Pfad des TUI-Gateways, der ein entferntes Bild in eine Data-URL umwandelt;
  • das Laden des Sitemap-Katalogs von skills.sh.

Eine Tür hatte ein Schloss. Die sieben Fenster daneben hatten keins — und das ist der häufigste wie lehrreichste Fehlermodus in der Sicherheitsarbeit.

Der Fix: drei Prüfungen, jede schwerer zu umgehen als die vorige

Neues wurde nicht erfunden. Der vorhandene Guard wurde vollständig angewendet, und die drei einfachen Bypässe wurden geschlossen:

  1. Prüfung vor dem Fetch. Jede Stelle ruft zuerst is_safe_url auf; eine abgelehnte Adresse baut nie eine Verbindung auf, und der Fehler benennt, was passiert ist: Blocked request to private/internal address: 127.0.0.1.
  2. Erneute Prüfung zum Zeitpunkt des Verbindungsaufbaus. Die Clients werden mit create_ssrf_safe_client erzeugt, der die Policy beim TCP-Connect erneut anwendet und die validierte IP anwählt, während Host/SNI erhalten bleiben. Das schließt das DNS-Rebinding-Fenster (TOCTOU): Wenn die erste Auflösung eine öffentliche Adresse liefert und die zweite eine interne, läuft eine Implementierung, die nur vor dem Fetch prüft, direkt hinein.
  3. Eine manuelle, begrenzte Redirect-Schleife. Höchstens fünf Hops, jeder einzelne neu validiert — und ein 3xx ohne Location-Header läuft jetzt fail-closed aus, statt als Bild im Cache zu landen. Vom Aufrufer mitgegebene Header (etwa der Bearer-Key für OpenRouter-Video) gelten nur für den ersten Hop, sodass ein Redirect auf eine andere Domain sie nicht mitschleppen kann.

Die Adressklassen, die der Guard ablehnt (laut offizieller Dokumentation):

  • private Netze (RFC 1918): 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
  • Loopback: 127.0.0.0/8, ::1
  • Link-Local: 169.254.0.0/16, was die Cloud-Metadaten unter 169.254.169.254 einschließt
  • CGNAT / Shared Address Space (RFC 6598): 100.64.0.0/10 — was Tailscale- und WireGuard-VPNs vergeben
  • Cloud-Metadaten-Hostnames: metadata.google.internal, metadata.goog
  • reservierte, Multicast- und Unspecified-Adressen

Zwei Defaults geben den Ton an: DNS-Fehler gelten als blockiert (fail-closed), und jeder Hop einer Redirect-Kette wird neu validiert.

Wenn du wirklich interne URLs brauchst: zwei Opt-ins

Das ist kein pauschales Verbot, und die Dokumentation zieht die Grenze sorgfältig:

# Globales Opt-out (Default: false) — eine ausdrückliche Trust-Boundary-Entscheidung
security:
  allow_private_urls: true

# Enger gefasst: nur der eigene Fake-IP-Block des lokalen Proxy
security:
  fake_ip_ranges:
    - 198.18.0.0/15
  • allow_private_urls passt für Heimnetze, die home.arpa in RFC-1918-Adressen auflösen, für Ollama- oder llama.cpp-Endpoints nur im LAN und für interne Wikis. Die Doku ist deutlich, was das bedeutet: Das ist eine bewusste Trust-Boundary — aktiviere es nur dort, wo „der Agent auf dieser Maschine lässt sich per Prompt-Injection dazu bringen, das lokale Netz anzugreifen“ ein akzeptables Risiko ist, und lass es auf öffentlich erreichbaren Gateways aus. Ein Detail am Rande: Der Schutz gegen Host-Substrings, der täuschend ähnliche Unicode-Domains blockiert, bleibt unabhängig von dieser Einstellung aktiv.
  • fake_ip_ranges ist für TUN-Proxys im Fake-IP-Modus gedacht (Mihomo/Clash Fake-IP, Surge Enhanced). Diese Proxys beantworten jeden Namen außerhalb ihres Filters mit einer Adresse aus ihrem eigenen Block (198.18.0.0/15 per Default), sodass der Privatadressen-Guard sonst jeden ausgehenden Fetch mit „URL targets a private or internal network address“ ablehnen würde, obwohl die Anfrage das Netzwerk nie erreicht. Gibt man den Block an, kommt nur dieser Block durch; Loopback, RFC 1918, Link-Local, CGNAT und Metadaten-Ziele bleiben gesperrt. Ein Eintrag, der eine dieser Klassen überlappt — 0.0.0.0/0 oder ::/0 —, wird mit einer Warnung ignoriert, statt den Guard still auszuweiten.

Es gibt noch eine Ausnahme, die im Alltag zählt: Die eigene base_url des Providers ist keine „fremde URL“. Ein Download, der direkt vom konfigurierten base_url geholt wird (Beispiel: der OpenRouter-Endpoint für Videoinhalte), überspringt in diesem ersten Hop die Prüfung der Privatadressen-Klasse, aber die Untergrenze für Cloud-Metadaten gilt weiterhin, und jeder von ihm ausgelöste Redirect wird vollständig neu validiert. Wenn du also einen Bildgenerierungs-Provider im LAN betreibst und willst, dass seine Ergebnis-URLs lokal in den Cache kommen, dann ist das genau der Fall, für den es security.allow_private_urls: true gibt.

Was der PR gemessen hat: vorher neun von neun Treffern, danach null

Die Verifikation ist es wert, zitiert zu werden, weil sie tatsächlich etwas beweist. Ein Listener auf Loopback protokolliert jede Verbindung, und der echte Codepfad steuert jede Aufrufstelle an:

Szenario Vorher Nachher
http://127.0.0.1:PORT/... an allen neun Stellen der Listener protokollierte an jeder Stelle einen Treffer; Bodies wurden zurückgegeben und gecacht null Treffer; jede Stelle warf ihren eigenen Fehlertyp (_image_to_data_url gibt None zurück) mit Blocked request to private/internal address: 127.0.0.1
ein sicherer erster Hop, dann 302 auf 169.254.169.254 ein echter Verbindungsversuch dieser Hop wird abgelehnt
ein 3xx ohne Location-Header der 3xx-Body wurde als Bild gecacht läuft fail-closed aus

Was du in deiner eigenen Installation tun solltest

Drei praktische Schritte, der wichtigste zuerst:

  1. Auf einem öffentlich erreichbaren Gateway: Stelle sicher, dass das Opt-out aus ist. Führe hermes config get security.allow_private_urls aus; nicht gesetzt bedeutet Default, und der ist aus. Den Schalter nach einem einmaligen Test mit einem internen Dienst anzulassen, ist ein häufiger und sehr leiser Fehler.
  2. Wenn du Bildgenerierung oder ein lokales Modell im LAN betreibst, widerstehe der Versuchung, gleich den globalen Schalter umzulegen. Wenn du nur willst, dass die Ergebnis-URLs eines Providers lokal in den Cache kommen, ist das der eine richtige Hebel. Und wenn du hinter einem Fake-IP-TUN-Proxy sitzt, nimm lieber die engere Variante fake_ip_ranges.
  3. Auf Cloud-Maschinen kommt gesunder Menschenverstand dazu. Dieser Guard ist eine Tür, um die man nicht herumgeht, aber Instance-Metadata-Credentials sind auch über Terminal-Befehle erreichbar — und diese Seite deckt ein Approval-Gate ab, das wir in dem Beitrag zum Approval-Gate für 169.254.169.254 behandelt haben. Zusammen ergibt das das vollständige Bild.

Verwandte Lektüre: der Fix am Closed Schema für Credentials im MCP-Katalog (Einträge Dritter dürfen keine beliebigen Umgebungsvariablen schreiben können) und das Review-Modell des offiziellen Remote-MCP-Katalogs. Die Sicherheitsdokumentation selbst steht unter hermes-agent.nousresearch.com/docs/user-guide/security.

Release-Status

PR #114887 wurde am 18. September 2026 in main gemergt, und der neueste Tag ist weiterhin v0.21.3 (Tag-Name v2026.9.14, veröffentlicht am 14. September), er ist also noch in keinem getaggten Release. Eine Git-Installation nimmt ihn beim nächsten Update mit:

hermes --version
hermes update

Docker-Image- und Cloud-Nutzer bekommen ihn mit dem nächsten Tag.

Die Lehre

Auf den Kern gebracht lautet die Regel: Ein Guard ist nur so gut wie die Zahl der Eingänge, die er abdeckt — nicht wie streng er geschrieben ist. Sieben Fetch-Stellen nutzten noch rohe HTTP-Clients, und mindestens zwei Angriffsklassen — DNS-Rebinding und ein 3xx ohne Location — hebeln das Muster „erst prüfen, dann fetchen“ komplett aus. Die robuste Form macht alle drei Dinge gleichzeitig: am Eingang validieren, beim Verbindungsaufbau noch einmal validieren, jeden Hop validieren — und den Leuten, die interne Zugriffe wirklich brauchen, einen ausdrücklichen, auditierbaren und so eng wie möglichen Schalter geben.