Deux connexions dans la même seconde, deux clés identiques : dans les coulisses du bug d'isolation de session de la webapp Hermes

Un collègue a besoin d’emprunter votre portable. Vous ouvrez le workspace Hermes dans le navigateur, cliquez sur la déconnexion, voyez apparaître le formulaire de connexion, et faites glisser la machine sur le bureau. Pour vous, l’affaire est réglée.
Elle ne l’était pas. Un correctif fusionné le 26 octobre (PR #145108) explique la brèche : si un autre chemin de connexion se termine dans la même seconde que votre connexion navigateur, les deux sessions reçoivent des tokens identiques octet pour octet. La déconnexion a supprimé le cookie du navigateur ; la même clé continuait de fonctionner ailleurs — y compris après un redémarrage.
Comment fonctionne réellement cette connexion
La webapp Hermes se trouve derrière une porte de connexion : tout navigateur doit s’authentifier d’abord. Elle s’appuie sur le système de comptes du dashboard, et le fournisseur de mot de passe intégré frappe deux tokens par connexion —
- un token d’accès, de courte durée, qui prouve qui émet cette requête ;
- un token de rafraîchissement, de plus longue durée, échangé contre de nouveaux tokens d’accès.
Les deux sont des chaînes signées portant trois faits : sub (qui vous êtes), kind (accès ou rafraîchissement) et exp (quand cela expire). Le serveur vérifie la signature et n’a pas besoin d’interroger une base de données à chaque requête.
La conception est correcte. Le problème venait de ce qui était signé. Ces trois champs constituaient toute la charge utile, et exp a une granularité à la seconde. Deux sessions frappées pour le même utilisateur en l’espace d’une seconde produisaient donc exactement la même entrée de signature — et donc exactement les mêmes octets de token.
Pourquoi une collision avait de l’importance
Des tokens identiques ne seraient qu’une curiosité si les sessions étaient indépendantes. Mais la webapp a un contrat explicite : les applications natives qui se sont connectées sur le listener de la webapp restent connectées malgré hermes webapp --sign-out ; seuls les navigateurs sont déconnectés. C’est délibéré — se déconnecter dans un navigateur ne doit pas mettre hors ligne votre application de bureau.
Pour honorer ce contrat, le listener enregistre les empreintes de la paire de tokens remise à une application native dans webapp/native-sign-in-tokens.json, et conserve délibérément cet enregistrement au-delà de la déconnexion — c’est pourquoi l’application de bureau survit à un redémarrage sans nouvelle connexion.
C’est là que la boucle se referme. Si une connexion navigateur et une connexion native tombent dans la même seconde, leurs tokens sont identiques → les tokens du navigateur correspondent aussi à cet enregistrement d’application native → vous vous déconnectez, le cookie du navigateur est supprimé, mais la même chaîne fonctionne toujours comme token bearer, et son token de rafraîchissement se renouvelle encore sur /auth/native/refresh. Le test énonce le résultat sans détour : cela fonctionne avant la déconnexion, après, et après un redémarrage.
Autrement dit : cette déconnexion a pris effet au premier plan, pas en arrière-plan.
Cinq lignes, mais visant la racine
Le changement se trouve dans _mint_session, dans plugins/dashboard_auth/basic/__init__.py : chaque session frappée reçoit un jti aléatoire (JWT ID, le champ standard pour numéroter un token individuel), signé à la fois dans le token d’accès et dans le token de rafraîchissement.
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) fournit 128 bits aléatoires — de quoi garantir que « même seconde, même utilisateur, deux connexions » produise tout de même deux paires distinctes. La vérification ne change pas : même contrôle de signature, même contrôle d’expiration. Seule la charge utile signée a gagné un champ que personne ne peut prédire.
Les tests se lisent comme le rapport de bug. Figez l’horloge, faites deux connexions et un renouvellement, et vérifiez trois paires distinctes, toutes valides. Un second test déroule une connexion native RFC 8252 complète (le navigateur système ouvre la page d’autorisation, connexion par mot de passe, code échangé contre des tokens), puis effectue une connexion navigateur dans la même seconde figée, et vérifie que les tokens du navigateur sont refusés seuls comme bearer avant la déconnexion, après, et après un redémarrage — tandis que l’application native reste connectée exactement comme avant.
Trois leçons à retenir
Un : tout schéma de signature où « même contenu ⇒ même token » a besoin d’un champ imprévisible. Ne signer que des champs métier (sub, kind, exp) fait de l’horodatage votre seule source d’unicité, et à la granularité de la seconde, la concurrence entre en collision. C’est précisément le rôle de jti. Si vous frappez vos propres tokens, ajoutez une empreinte aléatoire ou utilisez un mécanisme qui garantit l’unicité.
Deux : les contrôles de sécurité doivent couvrir plusieurs points d’entrée vers une même identité. La vérification de signature n’avait rien de fautif. Ce qui a mal tourné, c’est que deux entrées — cookies de navigateur et tokens bearer natifs — partageaient une seule façon de reconnaître un identifiant : comparer des empreintes. Comparer des empreintes est légitime ; le défaut était que les deux clés comparées pouvaient être la même clé. Décider « est-ce la session qui s’est déconnectée » exige l’identité de session, pas le contenu de l’identifiant.
Trois : la déconnexion a besoin d’une frontière sémantique explicite. Aujourd’hui le contrat est clair : hermes webapp --sign-out ne déconnecte que les navigateurs. Quelqu’un a bien demandé que les applications natives soient déconnectées aussi (issue #144668), et la réponse des mainteneurs est que, dans le contrat actuel, cela fonctionne comme prévu — une décision produit, pas une vulnérabilité. Mais vous devriez savoir ce que fait réellement le bouton sur lequel vous avez cliqué.
Que faire
Le correctif a été fusionné sur main le 26 octobre ; la v0.22.5 (sortie le 23 octobre) ne le contient pas, il arrivera donc dans la prochaine version. D’ici là, si la machine est partagée ou si vous confiez votre navigateur à quelqu’un :
- ne comptez pas sur la seule déconnexion. La vraie isolation consiste à désactiver le listener de la webapp (
webapp.enabled: false, puis redémarrer le gateway) ou à le relier de nouveau à127.0.0.1; - sur une machine partagée, donnez des profils séparés aux gens plutôt que de partager la connexion d’un seul profil ;
- redémarrez le gateway avant de passer la machine à quelqu’un, pour que l’état de session en mémoire disparaisse avec lui.
Pour la mécanique environnante, comment le lien de connexion à usage unique est transmis et ce qu’est la webapp et comment l’activer sont les deux compléments. Si votre dashboard est exposé aussi, le raisonnement de l’article sur le durcissement de la connexion au dashboard s’applique à la même frontière.
La leçon en une ligne : la déconnexion doit mettre fin à « cette session », pas à « cette chaîne ». Quand les deux cessent d’être en correspondance univoque, se déconnecter ferme la porte pendant que la clé reste dans la poche de quelqu’un.