Eine URL, zwei Hosts: Wie Hermes die Lücke zwischen den Parsern in seinem SSRF-Guard geschlossen hat

Jemand schickt dir einen Link per DM, und du reichst ihn an deinen Agenten weiter: hol das ab, überflieg es, sag mir, ob es relevant ist. Es funktioniert, die Zusammenfassung kommt an, nichts wirkt seltsam. Hinter diesem unspektakulären Erfolg waren sich zwei Codeteile uneinig darüber, worauf der Link eigentlich zeigt — die Prüfung, die entscheidet „ist diese Adresse erlaubt“, und der Client, der dann wirklich einen Socket geöffnet hat. Die Prüfung hielt es für eine öffentliche IP. Der Browser las dieselben Zeichen als Port auf deiner eigenen Maschine. PR #145578, gemergt am 27. Oktober, schließt diese Lücke: Hat eine URL zwei plausible Lesarten, lehnt Hermes sie jetzt ab, statt sich für eine zu entscheiden.
Prüfung und Verbindung lasen unterschiedliche Strings
Bevor Hermes irgendetwas abruft, screent es das Ziel: Loopback, private Bereiche und Cloud-Metadata-Endpunkte sollen tabu sein. Diese Logik liegt in tools/url_safety.py, mit is_safe_url() als Hauptentscheider und is_always_blocked_url() als gröberem Backstop.
Der Screening-Code parst die URL mit Pythons urllib.parse — genauer mit urlsplit. Der Teil, der am Ende die Verbindung öffnet, tut das oft nicht. Das Browser-Tool sitzt auf Chromium. Node-basierte Fetch-Backends (etwa ein selbst gehostetes Firecrawl) und urllib3 folgen jeweils eigenen Konventionen. Browser folgen dem WHATWG-URL-Standard. Gib denselben String beiden Stacks, und du kannst zwei verschiedene Hosts bekommen — und der Host ist das Einzige, worauf die Sicherheitsprüfung schaut.
Vier URLs, zwei Antworten
Der PR liefert vier Fälle, die du direkt nachprüfen kannst:
| URL | Host laut urlsplit / httpx | Host laut WHATWG | Ergebnis auf main |
|---|---|---|---|
http://127.0.0.1:8080\@1.1.1.1 |
1.1.1.1 |
127.0.0.1:8080 |
is_safe_url liefert True |
http://%31%32%37.0.0.1/ |
%31%32%37.0.0.1 |
127.0.0.1 |
DNS-abhängig (True hinter einem Proxy, wenn DNS fehlschlägt) |
http://***@1.1.1.1/ |
1.1.1.1 |
169.254.169.254 |
is_always_blocked_url liefert False |
http:\\169.254.169.254\ |
keiner | 169.254.169.254 |
is_always_blocked_url liefert False |
Die erste Zeile ist die deutlichste Illustration. urlsplit sieht das @, entscheidet, dass alles davor ein Credential-Paar ist, und meldet als Host 1.1.1.1 — öffentlich, in Ordnung, weiter. Chromium beendet die Authority am Backslash und verbindet sich munter mit 127.0.0.1:8080. Eine Adresse, die als „im Internet“ durchging, landet auf einem Loopback-Port.
Die zweite Zeile ist ein percent-encodierter Host. Die Prüfung sieht den Literaltext %31%32%37.0.0.1, der Browser dekodiert ihn zu 127.0.0.1. Auf main hängt das Urteil sogar von DNS ab: Mit konfiguriertem Proxy und fehlschlagender Auflösung gibt die Funktion True zurück.
Die Zeilen drei und vier nehmen einen anderen Weg. is_always_blocked_url() hat keine von beiden blockiert — und auf diese Funktion verlassen sich die Pfade, die is_safe_url() überspringen: das lokale Chromium-Backend und der Hybrid-Sidecar. Die letzte Verteidigungslinie für Metadata-Adressen hatte dasselbe Loch.
Wo die beiden Lesarten auseinandergehen
WHATWG macht drei Dinge, die urlsplit nicht macht:
- Backslash ist ein Slash. Bei Special Schemes wie http ist ein Backslash gleichwertig zu
/, und die Authority endet am ersten. Die Authority inhttp://127.0.0.1:8080\@1.1.1.1ist also einfach127.0.0.1:8080. - Der Host wird percent-dekodiert.
%31%32%37ist127. - Fehlende Slashes werden repariert.
http:\\hostundhttp:///hostnormalisieren beide zuhttp://host.
urlsplit macht nichts davon. Es behandelt Backslash nicht speziell, dekodiert den Host nicht und liest ein fehlendes // als „hier gibt es überhaupt keine Authority“. Damit hat „zeigt diese URL in mein Netz hinein?“ keine eindeutige Antwort — es kommt darauf an, wen du fragst. Die Sicherheitsprüfung fragte den einen Stack, der Client nutzte den anderen. Der Raum dazwischen ist der Bypass.
Der Fix: Mehrdeutigkeit ablehnen statt auflösen
Statt einem Parser beizubringen, den anderen nachzuahmen, geht der Patch den umgekehrten Weg: Wenn zwei Stacks aus demselben String unterschiedliche Hosts lesen könnten, gilt das als unsicher. Der Fachbegriff dafür ist fail closed — eine Adresse ablehnen, die vielleicht in Ordnung gewesen wäre, statt zu raten, welche Lesart der Client verwendet.
Das steckt in einem neuen Helper, _authority_ambiguity(), ab Zeile 253 in tools/url_safety.py. Bei http(s)-URLs entfernt er gewöhnlichen Whitespace am Anfang und Ende (Space, Tab, CR, LF, VT, FF) und lehnt die URL dann ab, wenn einer der folgenden Punkte zutrifft:
- Irgendwo steht ein C0-Steuerzeichen oder DEL — auch am Ende, wie in
http://169.254.169.254\x01, weilurlsplitTabs, CR und LF stillschweigend verwirft und dieselben Bytes auf beiden Seiten damit unterschiedliches bedeuten können. - Die
//-Authority fehlt:http:\\host,http:///host,http:host. - In der Authority steht irgendwo ein Backslash.
- Die Authority enthält
@(Userinfo, wasis_safe_url()ohnehin schon komplett abgelehnt hat). - Der Host enthält
%oder Whitespace. - Der Port ist ungültig — nicht numerisch oder außerhalb des Bereichs.
is_safe_url() ruft ihn direkt nach der Scheme-Prüfung auf. Bei einer Ablehnung landet nur der Grund im Log, nie die URL — URLs können Credentials tragen, und eine Logdatei ist nur ein weiterer Ort, an dem sie lecken können.
Warum die Fallback-Prüfung lockerer statt strenger wurde
is_always_blocked_url() (Zeile 342) bedient die Backends, die is_safe_url() nie aufrufen: lokales Chromium und der Hybrid-Sidecar. Sie lehnt nur ab, was einen Client zu einem anderen Host bringen kann: Steuerzeichen, eine fehlende //-Authority, ein Backslash in der Authority. Percent-encodierte Hosts werden nach dem Dekodieren beurteilt, http://%31%36%39.254.169.254/ fällt also auf.
Ungültige Ports, Whitespace im Host und Userinfo ändern nicht den Host, mit dem der Client am Ende spricht, deshalb ignoriert der Fallback sie bewusst. Der Grund ist konkret: browser_exec baut seine URLs aus Template-Literals — f"http://localhost:{port}/", "http://localhost:%d/" und ein JS-`http://localhost:${port}/api` — und ein strikter Fallback würde sie alle auf dem lokalen Browser-Backend brechen. Strenge bleibt dort, wo sie hingehört, in is_safe_url(); der Fallback sichert die eine Eigenschaft, auf die es ankommt: dass der Host nicht auf zwei Arten gelesen werden kann.
Ein Detail, das leicht übersehen wird: Beide Funktionen nehmen den Host aus demselben bereits gestrippten String, den sie für die Steuerzeichen-Prüfung benutzt haben, und vergleichen dann den IDNA-kodierten Host mit der Metadata-Adresse, nachdem U+3002, U+FF0E und U+FF61 auf . gemappt wurden. Praktisch heißt das: http://169.254.169.254。/, Formen mit Vollbreiten- oder Halbbreiten-Punkten und Vollbreiten-Ziffern lösen für diesen Zweck alle zu 169.254.169.254 auf — selbst wenn DNS fehlschlägt und ein Proxy konfiguriert ist.
Nicht-http(s)-URLs wie about: und data: bleiben von der Änderung unberührt.
Zwei Begriffe, die man sauber trennen sollte
SSRF — server-side request forgery. Du lässt einen Server eine URL in deinem Auftrag abrufen; ein Angreifer bastelt diese URL so, dass der Server etwas erreicht, was er selbst nicht erreichen kann: einen Loopback-Port, ein internes Admin-Panel, eine Cloud-Metadata-API. Dein Dienst wird zum Proxy und zum Standbein des Angreifers im Netz. Bösartig ist das, weil die Anfrage wirklich von innen kommt — Perimeter-Regeln haben nichts, woran sie sich stoßen könnten, denn der Aufrufer ist per Definition „einer von uns“.
169.254.169.254 ist die Link-Local-Adresse, die Cloud-Anbieter auf jeder Instanz als Metadata-Service bereitstellen. Sie liefert mehr als Hostname und Region; typischerweise gibt sie auch temporäre Credentials auf Instanzebene heraus. Wer einen Server dazu bringt, diese Adresse abzurufen, kann mit der Cloud-Identität der Maschine davonziehen, und was diese Identität darf, hängt an der Rolle, die der Instanz zugewiesen ist. Deshalb wird sie als eigene, niemals erlaubte Adressklasse behandelt und nicht in die allgemeine Prüfung privater Bereiche eingefaltet.
Beides zusammengenommen ergibt diesen Bug: eine URL, die den Prüfer überzeugt, sie zeige ins öffentliche Internet, und ein Host, den der Client als Loopback oder als Metadata liest.
Was du damit machen solltest
- Bleib aktuell.
hermes update. Die Änderung ist am 27. Oktober aufmaingelandet, und der PR vermerkt ausdrücklich, dass es kein zugehöriges Issue gibt — das ist proaktives Härten, kein Patch für einen bekannten Exploit im Umlauf. - Rechne mit der neuen Semantik: mehrdeutig heißt abgelehnt. Wenn du Automatisierung hast, die absichtlich clevere URLs dieser Form baut, wird sie jetzt zurückgewiesen. Das ist gewollt, keine Regression; gewöhnliche URLs sind nicht betroffen.
- Das Muster lässt sich verallgemeinern. Der frühere Fix, der Loopback als keine Vertrauensgrenze für den A2A-Port etabliert hat, beruht auf derselben Idee: Eine Sicherheitsentscheidung ist nur so gut wie ihre Übereinstimmung mit der Komponente, die die Aktion tatsächlich ausführt.
- Wenn dich die Flächen interessieren, über die ein Agent nach außen reichen kann, lies die Security-Hinweise zum MCP-Katalog zusammen mit der Kontrollfläche der Browser-Extension — beim ersten geht es darum, was du installierst, beim zweiten darum, wie viel Autorität die Browserseite hält. Dieser Bug saß genau dort, wo sich beide treffen.
Die Lehre, wenn es eine Zeile sein soll: Der Host, den dein Validator liest, muss der Host sein, den dein Client anwählt. Wo sich das nicht garantieren lässt, ist fail closed die einzige ehrliche Antwort.