127.0.0.1 ist keine Sicherheitsgrenze: Hermes bringt seinem A2A-Port jetzt bei, Browser abzuweisen

Du hast die Doku befolgt, A2A aktiviert, an 127.0.0.1 auf Port 9900 gebunden und den Token weggelassen — weil „nur diese Maschine kommt daran“ schon sicher klingt. Am nächsten Tag öffnest du irgendeine unbekannte Seite im Browser, und ein Skript darauf schickt eine Anfrage an http://127.0.0.1:9900/. Du gehst davon aus, dass der Browser das stoppt. Tut er nicht: Aus Sicht des Browsers sind diese Seite und diese Anfrage zwei Ports auf derselben Maschine, und die Anfrage darf problemlos hinaus.
Die Frage war nie „wer kann sich mit diesem Port verbinden“. Sie war „wer kann deinen Browser dazu bringen, diese Anfrage für ihn zu schicken“. Ein am 27. Oktober gemergter PR (#145310) schließt dieses Loch: Im Modus ohne Token weist der A2A-Listener jetzt browser-originierte POSTs ab und validiert Host auf jeder Route.
Was der Standardmodus tatsächlich vertraut
Die A2A-Plattform von Hermes arbeitet in beide Richtungen: Dein Agent kann andere A2A-Agents aufrufen (ein ausgehendes Toolset, standardmäßig aus), und andere Agents können Aufgaben an dein Hermes schicken (der eingehende Listener). Die eingehende Seite startet so:
gateway:
platforms:
a2a:
enabled: true
extra:
port: 9900
Mit weder gesetztem A2A_BEARER_TOKEN noch A2A_PEER_TOKENS gilt das Modell „kein Token ⇒ nur localhost“: Der Server bindet 127.0.0.1, und ein weiter gefasstes Binding erfordert einen Token und ein explizites A2A_HOST. In einer Welt von CLI-Prozessen und Agents trägt diese Argumentation — anderen Prozessen auf der eigenen Maschine zu vertrauen ist vernünftig.
Sie hat eine Klasse von Prozessen übersehen: den Browser. Ein Browser ist kein „lokales Programm, dem du vertraust“. Er ist eine Sandbox, die das ganze Internet lädt, und jede Seite darin kann an 127.0.0.1:9900 POSTen, solange die Anfrage eine „simple request“ ist — kein Preflight, keine Berechtigungsabfrage.
Tür eins: browser-originierte POSTs abweisen
Die Prüfung liegt in plugins/platforms/a2a/security.py unter einem Namen, der sagt, was sie tut: browser_post_refusal(). Im Modus ohne Token bekommen drei Formen von Anfragen ein 403:
- ein
Origin-Header ist vorhanden — echte Agent-Clients und SDKs senden ihn nicht, Browser immer; Sec-Fetch-Siteist etwas anderes alsnoneodersame-origin— der Browser kennzeichnet, von welcher Seite die Anfrage kommt;Content-Typeist nichtapplication/json(oder ein+json-Typ) — eine Seite kann den Preflight nur mit einer Handvoll einfacher Medientypen umgehen, und A2A spricht JSON-RPC, also per Definition JSON.
Der dritte Punkt ist der cleverste Schnitt, weil er sich nicht darauf verlässt, dass der Browser ehrlich darüber ist, woher die Anfrage kommt. Er verlangt, dass die Anfrage wie A2A aussieht. Eine Seite, die JSON senden will, muss einen Preflight auslösen, und der Preflight wird abgewiesen. Hermes’ eigener A2A-Client sendet bereits application/json und keinen dieser Browser-Header, Agent-zu-Agent-Aufrufe bleiben also unberührt.
Tür zwei: der Host muss ein Loopback-Name sein
Die zweite Tür bewacht einen anderen Angriff: DNS-Rebinding. Einfach gesagt: Ein Angreifer kontrolliert einen Hostnamen, lässt ihn zuerst auf seinen eigenen Server auflösen, sodass du eine Seite von dort lädst, und setzt dann eine sehr kurze DNS-TTL. Kurz darauf fragt die Seite denselben Hostnamen erneut an — und diesmal löst er auf 127.0.0.1 auf. Der Browser hält es weiterhin für dieselbe Seite, die Same-Origin-Policy schützt dich also nicht mehr, und das Skript kann lesen, was dein lokaler Dienst zurückgibt.
Der Fix prüft Host in A2ARequestHandler.parse_request(), bevor irgendeine Route läuft: Akzeptiert werden nur Loopback-Literale (localhost, 127.x.x.x, [::1], localhost.) und der Host aus A2A_PUBLIC_URL. Alles andere bekommt ein 403 — nicht einmal /health oder die Agent Card.
Warum A2A_PUBLIC_URL erlauben? Weil echte Deployments oft hinter einem Reverse-Proxy sitzen, der den empfangenen Hostnamen weiterreicht. Mit A2A_PUBLIC_URL erklärst du „dieser Dienst heißt da draußen so“, also wird ihm vertraut — und die Doku ergänzt den naheliegenden Vorbehalt: Setze ihn nur auf einen Namen, den du kontrollierst.
Token-authentifizierte Listener bleiben bewusst unberührt: Sobald ein Token existiert, entscheidet der Token, wer hineinkommt. Das ist Arbeitsteilung, kein Versehen.
Die eigene Instanz prüfen
Sobald du auf einem Build bist, der die Änderung enthält, probiere drei Dinge von einer anderen Maschine oder einem anderen Prozess aus. Erstens: Ein normaler Agent-Card-Abruf sollte gelingen:
curl -s http://your-host:9900/.well-known/agent-card.json | head -20
Zweitens: Ein gefälschter fremder Host sollte 403 zurückgeben:
curl -s -o /dev/null -w '%{http_code}\n' \
-H 'Host: evil.example' \
http://127.0.0.1:9900/.well-known/agent-card.json
Drittens: Ein POST mit Origin sollte ebenfalls 403 zurückgeben:
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":{}}'
Wenn die Browser-Konsole einen CORS-Fehler statt eines 403 zeigt, hat der Browser die Anfrage zuerst gestoppt — auch in Ordnung, nur eine andere Schicht.
Drei Dinge, die Leute falsch machen
Eins: kein Token heißt nicht kein Berechtigungsmodell. Neben diesen zwei Türen bringt A2A bereits eine Allow-List A2A_TRUSTED_PEERS mit, Rate-Limiting pro Identität (A2A_RATE_LIMIT, standardmäßig 60 Anfragen pro Minute pro Identität), Prompt-Injection-Filterung für eingehenden Text, Credential-Redaktion in ausgehenden Antworten und ein Audit-Log unter ~/.hermes/a2a_audit.jsonl. Ein Detail, das man kennen sollte: Binde an eine Nicht-Loopback-Adresse ohne A2A_TRUSTED_PEERS zu setzen, und jeder Dispatch wird mit 403 abgewiesen. Das ist Fail-Closed-Verhalten, kein Bug.
Zwei: für maschinenübergreifende Arbeit Token verwenden. Mach beides, in dieser Reihenfolge: Setze A2A_PEER_TOKENS (alice:tok1,bob:tok2, ein Credential pro Peer — viel leichter zu debuggen als ein gemeinsames Secret), und erst dann A2A_HOST. Die Bind-Adresse ist keine Berechtigung; der Token ist es. Dieselbe Argumentation taucht in der Härtung des Dashboard-Logins auf.
Drei: die öffentliche URL nicht improvisieren. Eine falsche A2A_PUBLIC_URL bewirbt eine Card, die auf eine Adresse zeigt, die Peers nicht erreichen, und das Symptom ist „die andere Seite läuft ständig in einen Timeout“, nicht eine Fehlermeldung. Die vollständige Befehls- und Konfigurationsoberfläche steht in der Gateway-Befehlsreferenz; wenn das deine erste Installation ist, geh vor dem A2A-Abschnitt durch die Installationsanleitung.
Kann man es schon nutzen
Die Änderung wurde am 27. Oktober nach main gemergt. v0.22.5 wurde am 23. Oktober ausgeliefert, also enthalten selbst die Canary-Tags von diesem Tag sie nicht — die Zeitleiste kannst du auf der Release-Seite zu v0.22.5 nachsehen. Warte auf das nächste Release oder verfolge main.
Eine Faustregel, die mehr wert ist als jedes Config-Snippet: Wenn ein Port von einer Webseite in deinem Browser erreichbar ist, ist er nicht „nur lokal“ — er ist über deinen Browser für das ganze Internet erreichbar. Entwirf die Sicherheit lokaler Dienste für diese Prämisse.