Dein Dashboard-Login-Limit war mit einem einzigen Header aushebelbar

Du hast einen Nachmittag damit verbracht, das Hermes-Web-Dashboard hinter nginx zu setzen, ein Passwort zu vergeben, HTTPS zu aktivieren und den Port ins Internet zu öffnen. Du hast dir gesagt, das sei in Ordnung: Der Login-Endpunkt erlaubt zehn Versuche pro Minute, ein Brute-Force-Angriff ist also mathematisch aussichtslos. Dann stellt sich heraus, dass der Angreifer bei jeder Anfrage nur einen anderen X-Forwarded-For-Wert senden muss, um unbegrenzt viele Versuche zu bekommen — denn das Rate-Limit wird nach „Client-IP“ gruppiert, und die Client-IP stammte aus einem Header, den der Angreifer selbst geschrieben hat.
Am 5. Oktober 2026 hat das Hermes-Repository drei Härtungs-Fixes für die Dashboard-Authentifizierung an einem einzigen Tag zusammengeführt (PRs #133367, #133369, #133370). Keiner davon ist ein Feature. Alle drei sind dieselbe Fehlerklasse am selben Einstiegspunkt: Eingaben ohne Obergrenze, denen man vertraut, weil sie angekommen sind. Hier steht, was jeder Einzelne war, warum er wichtig war, wie das korrigierte Verhalten aussieht und ob deine eigene Deployment-Konfiguration angepasst werden muss.
Was das Rate-Limit eigentlich leisten sollte
Der Passwort-Login des Dashboards liegt unter /auth/password-login. Er ist pro Client-IP rate-limitiert, standardmäßig zehn Versuche pro Minute, danach kommt HTTP 429 zurück. Ein unbekannter Nutzer und ein falsches Passwort liefern beide dieselbe generische Antwort 401 Invalid credentials, sodass der Endpunkt nicht als Orakel zur Ermittlung von Benutzernamen missbraucht werden kann.
Das Design ist in Ordnung. Das Problem war, woher die „Client-IP“ kam.
Eins: Die Client-IP war, was der Client behauptet hat
client_ip() in hermes_cli/dashboard_auth/request_utils.py gab früher den ersten Wert des X-Forwarded-For-Request-Headers zurück. Dieser Header ist konstruktionsbedingt eine Information, die jemand anderes in die Kette geschrieben hat — und ein Client kann ihn fälschen. Also:
- ein anderer
X-Forwarded-For-Wert bei jeder Anfrage erzeugte einen frischen Rate-Limit-Bucket, wodurch die Grenze von zehn pro Minute bedeutungslos wurde; - das Pending-Limit pro IP beim nativen Sign-in-Flow ließ sich auf demselben Weg umgehen;
- die im Auth-Audit-Log festgehaltene Quelladresse wurde zu dem, was der Angreifer gerade schreiben wollte.
Der Fix ist unumwunden: client_ip() gibt jetzt nur noch den ASGI-Peer (request.client.host) zurück und liest den rohen Header nie. Was ist mit dem Reverse-Proxy-Fall? Uvicorns ProxyHeadersMiddleware schreibt diesen Peer nur dann aus X-Forwarded-For um, wenn die Anfrage tatsächlich von einem vertrauenswürdigen Proxy kommt — Loopback oder eine in dashboard.trusted_proxies gelistete Adresse —, und sie nimmt den rechtesten Hop, der nicht vertrauenswürdig ist, sodass ein normaler Reverse-Proxy jedem echten Client weiterhin seinen eigenen Bucket gibt.
Zwei: Ein einziges Feld konnte deine Festplatte füllen
POST /auth/password-login ist unauthentifiziert. Früher kopierte es den provider-String der Anfrage wortwörtlich in dashboard-auth.log, sowohl auf dem Pfad für unbekannte Provider (404) als auch auf dem rate-limitierten Pfad (429). Eine einzige Anfrage konnte dadurch einen Log-Eintrag beliebiger Größe schreiben: Die Probe des PRs selbst zeigt einen provider mit 100.000 Zeichen, der vor dem Fix etwa 100 KB im Log hinterließ, nach dem Fix 336 Zeichen.
provider ist jetzt auf 128 Zeichen begrenzt. FastAPI weist längere Werte mit einem 422 ab, bevor der Handler läuft, sodass nichts geloggt wird. Darüber hinaus begrenzt audit_log() jeden String-Wert auf 256 Zeichen — einschließlich verschachtelter Werte und des repr von Nicht-JSON-Werten —, damit ein künftiges Audit-Feld nicht denselben unbegrenzten Schreibvorgang wieder einführt. Es gibt genau einen Seiteneffekt: Ein benutzerdefinierter Passwort-Provider, dessen ID länger als 128 Zeichen ist, kann sich nicht mehr anmelden. Die mitgelieferten Provider-IDs (basic, nous, self-hosted, drain-secret) sind deutlich kürzer.
Drei: Die öffentlichen Auth-Routen hatten kein Body-Size-Limit
Die drei öffentlichen POST-Routen unter /auth/ — /auth/password-login, /auth/native/token, /auth/native/refresh — passieren das Auth-Gate, danach puffert FastAPI den gesamten JSON-Body, bevor er validiert wird. Es gab kein Größenlimit, sodass ein anonymer Client das Dashboard dazu bringen konnte, einen großen oder nie endenden Upload im Speicher zu halten.
Eine neue reine ASGI-Middleware AuthBodyLimitMiddleware setzt ein Budget von 64 KiB auf öffentliche /auth/-POSTs:
- ist
Content-Lengthüber dem Limit, antwortet sie mit 413, bevor auch nur ein einziges Body-Byte gelesen wird; - sie zählt außerdem die tatsächlich empfangenen Bytes, sodass ein chunked Body oder einer mit zu niedrig angegebenem
Content-Lengtham selben Limit abgeschnitten wird.
Die Middleware ist am innersten registriert — keine äußere HTTP-Middleware liest Request-Bodies —, sodass das Limit greift, bevor irgendetwas puffert. Authentifizierte Routen, Uploads und WebSockets bleiben unverändert.
Was dein eigenes Deployment ändern muss
Alle drei Fixes sind serverseitiges Verhalten; du musst keinen Code schreiben. Das Einzige, was einen Blick wert ist, ist dashboard.trusted_proxies: Nur dort gelistete Peers (plus Loopback, dem immer vertraut wird) dürfen X-Forwarded-For liefern. Unbegrenzte Einträge wie *, 0.0.0.0/0 und ::/0 werden abgelehnt.
dashboard:
public_url: "https://dashboard.example.com/hermes"
trusted_proxies:
- "172.20.0.0/24"
Wenn du deinen Reverse-Proxy nicht auflistest, können Clients dahinter ihre eigene Adresse nicht präsentieren, sodass sich alle Clients hinter diesem Proxy ein Passwort-Login-Budget teilen und Audit-Events die Adresse des Proxys festhalten. Das ist auf der sicheren Seite — ein geteilter Bucket ist besser als ein fälschbarer —, macht das Rate-Limit aber ungenau, also bevorzuge eine exakte Proxy-IP oder ein begrenztes, nur für den Proxy bestimmtes Netz.
Es gibt eine grobe, aber wirksame Methode zum Prüfen: Triff den Login-Endpunkt elfmal und erwarte beim elften Mal 429. Mach es dann erneut mit einem gefälschten X-Forwarded-For bei jeder Anfrage; das elfte sollte weiterhin 429 sein. Wenn der zweite Durchlauf glatt durchgeht, hat deine Instanz noch das alte Verhalten.
Release-Status
Alle drei Fixes sind auf main und noch in keinem veröffentlichten Tag (die neueste Version ist v0.21.5, Tag v2026.9.24). Git-Installationen bekommen sie, indem sie das neueste main ziehen; Docker-Images und gehostete Deployments erhalten sie, sobald der nächste Tag gebaut wird. Was du laufen lässt, kannst du mit hermes version bestätigen, und das vollständige Bild der Dashboard-Login- und Proxy-Konfiguration steht in der Dashboard-Befehlsreferenz.
Das Muster hinter allen drei
Diese drei Fixes haben eine gemeinsame Form: unauthentifizierte Eingaben, die behandelt werden, als wären sie vertrauenswürdig. Eine vom Client deklarierte Quelladresse, die als Identität gilt, ein vom Client übermittelter String, der als begrenztes Feld gilt, ein vom Client gesendeter Body, der als vernünftig groß gilt. Dieselbe Klasse taucht in jedem Dienst auf, der ins Internet schaut, und der Fix ist immer derselbe: Vertraue nur dem, was deine eigene Seite beobachtet hat, und setze eine Obergrenze auf alles, was ankommt.
Der Vollständigkeit halber brachte derselbe Tag auch Fixes zu Credential- und Dateigrenzen — der Profil-Export, der deine Secrets versenden konnte und die Zugriffsgrenze zwischen Profilen sind eine Lektüre wert neben diesen.