Zwei Logins in derselben Sekunde, zwei identische Schlüssel: im Inneren des Session-Isolations-Bugs der Hermes-Webapp


Eine Kollegin muss sich deinen Laptop ausleihen. Du öffnest den Hermes-Workspace im Browser, klickst auf Abmelden, siehst das Login-Formular erscheinen und schiebst die Maschine über den Tisch. Für dich ist die Sache erledigt.

War sie nicht. Ein am 26. Oktober gemergter Fix (PR #145108) erklärt das Loch: Wenn ein anderer Anmeldeweg zufällig in derselben Sekunde abgeschlossen wird wie dein Browser-Login, bekommen beide Sessions byte-identische Tokens. Das Abmelden entfernte das Cookie des Browsers; derselbe Schlüssel funktionierte anderswo weiter — auch nach einem Neustart.

Wie diese Anmeldung tatsächlich funktioniert

Die Hermes-Webapp sitzt hinter einem Sign-in-Gate: Jeder Browser muss sich zuerst authentifizieren. Sie nutzt das Account-System des Dashboards, und der eingebaute Passwort-Provider prägt pro Login zwei Tokens —

  • ein Access-Token, kurzlebig, das belegt, wer diese Anfrage stellt;
  • ein Refresh-Token, langlebiger, gegen neue Access-Tokens getauscht.

Beide sind signierte Strings, die drei Fakten tragen: sub (wer du bist), kind (Access oder Refresh) und exp (wann es abläuft). Der Server prüft die Signatur und braucht keinen Datenbank-Lookup pro Anfrage.

Das Design ist in Ordnung. Das Problem lag darin, was signiert wird. Diese drei Felder waren die gesamte Payload, und exp hat Sekunden-Granularität. Zwei Sessions für denselben Benutzer innerhalb einer Sekunde erzeugten daher exakt dieselbe Signatur-Eingabe — und damit exakt dieselben Token-Bytes.

Warum eine Kollision zählte

Identische Tokens wären eine Kuriosität, wenn die Sessions nichts miteinander zu tun hätten. Aber die Webapp hat einen expliziten Vertrag: Native Apps, die sich am Webapp-Listener angemeldet haben, bleiben über hermes webapp --sign-out hinweg angemeldet; nur Browser werden abgemeldet. Das ist Absicht — sich im Browser abzumelden soll deine Desktop-App nicht offline werfen.

Um das einzuhalten, zeichnet der Listener Digests des Token-Paars auf, das er einer nativen App gegeben hat, in webapp/native-sign-in-tokens.json, und behält diesen Eintrag bewusst über das Abmelden hinweg — deshalb übersteht die Desktop-App einen Neustart ohne frisches Login.

Hier schließt sich der Kreis. Wenn ein Browser-Login und eine native Anmeldung in derselben Sekunde landen, sind ihre Tokens identisch → die Tokens des Browsers passen ebenfalls zu diesem „native App“-Eintrag → du meldest dich ab, das Browser-Cookie fällt weg, aber derselbe String funktioniert weiter als Bearer-Token, und sein Refresh-Token erneuert weiterhin unter /auth/native/refresh. Der Test sagt das Ergebnis klar: Es funktioniert vor dem Abmelden, danach und nach einem Neustart.

Einfach gesagt: Dieses Abmelden wirkte im Vordergrund und nicht im Hintergrund.

Fünf Zeilen, aber auf die Wurzel gezielt

Die Änderung liegt in _mint_session in plugins/dashboard_auth/basic/__init__.py: Jede geprägte Session bekommt eine zufällige jti (JWT ID, das Standardfeld zur Nummerierung eines einzelnen Tokens), die in Access- und Refresh-Token signiert wird.

jti = secrets.token_urlsafe(16)
_sign({"sub": user, "kind": "access",  "exp": exp, "jti": jti}, secret)
_sign({"sub": user, "kind": "refresh", "exp": now + TTL, "jti": jti}, secret)

secrets.token_urlsafe(16) liefert 128 zufällige Bits — genug, dass „gleiche Sekunde, gleicher Benutzer, zwei Logins“ weiterhin zwei verschiedene Paare ergibt. Die Verifikation bleibt unverändert: gleiche Signaturprüfung, gleiche Ablaufprüfung. Nur die signierte Payload hat ein Feld bekommen, das niemand vorhersagen kann.

Die Tests lesen sich wie der Bug-Report. Uhr einfrieren, zwei Logins und eine Erneuerung durchführen und prüfen, dass es drei verschiedene Paare gibt, die alle verifizieren. Ein zweiter Test führt eine vollständige native Anmeldung nach RFC 8252 aus (Systembrowser öffnet die Authorize-Seite, Passwort-Login, Code gegen Tokens getauscht), macht dann einen Browser-Login innerhalb derselben eingefrorenen Sekunde und prüft, dass die Tokens des Browsers eigenständig als Bearer abgewiesen werden — vor dem Abmelden, danach und nach einem Neustart —, während die native App genau wie zuvor angemeldet bleibt.

Drei Lektionen, die es wert sind, behalten zu werden

Eins: Jedes Signatur-Schema, in dem „gleicher Inhalt ⇒ gleiches Token“ gilt, braucht ein unvorhersehbares Feld. Nur Business-Felder (sub, kind, exp) zu signieren macht den Zeitstempel zu deiner einzigen Quelle für Eindeutigkeit, und bei Sekundengranularität kollidiert Nebenläufigkeit. Dafür ist jti da. Wenn du eigene Tokens prägst, füge einen zufälligen Fingerprint hinzu oder nutze einen Mechanismus, der Eindeutigkeit garantiert.

Zwei: Sicherheitsprüfungen müssen mehrere Eintrittspunkte in eine Identität abdecken. An der Signaturprüfung war nichts falsch. Falsch war, dass zwei Eintritte — Browser-Cookies und native Bearer-Tokens — eine gemeinsame Art hatten, Credentials zu erkennen: Digests zu vergleichen. Digests zu vergleichen ist in Ordnung; der Fehler war, dass die beiden verglichenen Schlüssel derselbe Schlüssel sein konnten. Die Entscheidung „ist das die Session, die sich abgemeldet hat“ braucht Session-Identität, nicht Credential-Inhalt.

Drei: Abmelden braucht eine explizite semantische Grenze. Heute ist der Vertrag klar: hermes webapp --sign-out meldet nur Browser ab. Jemand hat durchaus gefragt, ob auch native Apps abgemeldet werden sollen (Issue #144668), und die Antwort der Maintainer ist, dass das unter dem aktuellen Vertrag wie beabsichtigt funktioniert — eine Produktentscheidung, keine Schwachstelle. Aber du solltest wissen, was der Knopf, den du gedrückt hast, tatsächlich tut.

Was du dagegen tun kannst

Der Fix wurde am 26. Oktober nach main gemergt; v0.22.5 (veröffentlicht am 23. Oktober) enthält ihn nicht, er landet also in der nächsten Version. Bis dahin, wenn die Maschine geteilt wird oder du deinen Browser jemandem gibst:

  • verlass dich nicht auf das Abmelden allein. Echte Isolation heißt, den Webapp-Listener abzuschalten (webapp.enabled: false, dann das Gateway neu starten) oder ihn wieder an 127.0.0.1 zu binden;
  • auf einer geteilten Maschine gib Leuten eigene Profile, statt das Login eines Profils zu teilen;
  • starte das Gateway neu, bevor du die Maschine weitergibst, damit der Session-Zustand im Speicher mit ihm verfällt.

Für die umliegende Mechanik sind wie der Einmal-Anmeldelink übergeben wird und was die Webapp ist und wie man sie aktiviert die beiden Anschlussstücke. Wenn dein Dashboard ebenfalls exponiert ist, gilt die Argumentation in dem Beitrag zur Härtung des Dashboard-Logins für dieselbe Grenze.

Die Ein-Satz-Lektion: Abmelden muss „diese Session“ beenden, nicht „diesen String“. Wenn beides nicht mehr eins zu eins ist, schließt das Abmelden nur die Tür, während der Schlüssel in jemandes Tasche bleibt.