Votre limite de connexion au tableau de bord était falsifiable avec un seul en-tête

Vous avez passé un après-midi à mettre le tableau de bord web Hermes derrière nginx, défini un mot de passe, activé HTTPS et exposé le port à internet. Vous vous êtes dit que tout allait bien : le point de connexion autorise dix tentatives par minute, donc la force brute est mathématiquement sans espoir. Puis il s’avère que l’attaquant n’a qu’à envoyer une valeur X-Forwarded-For différente à chaque requête pour obtenir un nombre illimité de tentatives — car la limite de débit est compartimentée par « client IP », et l’IP client provenait d’un en-tête que l’attaquant écrivait lui-même.
Le 5 octobre 2026, le dépôt Hermes a fusionné trois correctifs de durcissement de l’authentification du tableau de bord en une seule journée (PR #133367, #133369, #133370). Aucun n’est une fonctionnalité. Tous les trois relèvent de la même classe d’erreur sur le même point d’entrée : une entrée sans plafond, jugée fiable parce qu’elle est arrivée. Voici ce qu’était chacune, pourquoi elle comptait, à quoi ressemble le comportement corrigé, et si votre propre déploiement nécessite un changement de configuration.
Ce que la limite de débit était censée faire
La connexion par mot de passe du tableau de bord se trouve à /auth/password-login. Elle est limitée par IP client, dix tentatives par minute par défaut, et renvoie HTTP 429 au-delà. Un utilisateur inconnu et un mot de passe erroné renvoient tous deux le même 401 Invalid credentials générique, donc le point d’accès ne peut pas servir d’oracle d’énumération d’utilisateurs.
La conception est bonne. Le problème était l’origine de « client IP ».
Un : l’IP client était ce que le client prétendait
client_ip() dans hermes_cli/dashboard_auth/request_utils.py renvoyait autrefois la première valeur de l’en-tête de requête X-Forwarded-For. Cet en-tête est, par construction, une information écrite dans la chaîne par quelqu’un d’autre — et un client peut le falsifier. Donc :
- envoyer une valeur
X-Forwarded-Fordifférente à chaque requête produisait un nouveau compartiment de limite de débit, rendant le plafond de dix par minute dénué de sens ; - le plafond de requêtes en attente par IP sur le flux de connexion natif pouvait être contourné de la même manière ;
- l’adresse source enregistrée dans le journal d’audit d’authentification devenait ce que l’attaquant voulait bien y écrire.
Le correctif est radical : client_ip() ne renvoie désormais que le pair ASGI (request.client.host) et ne lit jamais l’en-tête brut. Et le cas du reverse proxy ? Le ProxyHeadersMiddleware d’Uvicorn ne réécrit ce pair à partir de X-Forwarded-For que lorsque la requête arrive réellement d’un proxy de confiance — loopback, ou une adresse listée dans dashboard.trusted_proxies — et il prend le dernier saut non fiable, donc un reverse proxy normal donne toujours à chaque vrai client son propre compartiment.
Deux : un seul champ pouvait remplir votre disque
POST /auth/password-login n’est pas authentifié. Il copiait autrefois la chaîne provider de la requête telle quelle dans dashboard-auth.log, à la fois sur le chemin de fournisseur inconnu (404) et sur celui limité en débit (429). Une seule requête pouvait donc écrire une entrée de journal de n’importe quelle taille : la sonde de la PR elle-même montre un provider de 100 000 caractères laissant environ 100 Ko dans le journal avant le correctif, et 336 caractères après.
provider est désormais plafonné à 128 caractères. FastAPI rejette les valeurs plus longues avec un 422 avant que le gestionnaire ne s’exécute, donc rien n’est journalisé. En plus de cela, audit_log() plafonne chaque valeur de chaîne à 256 caractères — y compris les valeurs imbriquées et le repr des valeurs non JSON — afin qu’un futur champ d’audit ne puisse pas réintroduire la même écriture sans borne. Il y a exactement un effet secondaire : un fournisseur de mot de passe personnalisé dont l’ID dépasse 128 caractères ne pourra plus se connecter. Les ID de fournisseurs livrés (basic, nous, self-hosted, drain-secret) sont bien plus courts.
Trois : les routes d’authentification publiques n’avaient aucune limite de taille de corps
Les trois routes POST publiques sous /auth/ — /auth/password-login, /auth/native/token, /auth/native/refresh — franchissent la barrière d’authentification, après quoi FastAPI met en mémoire tampon tout le corps JSON avant de le valider. Il n’y avait aucune limite de taille, donc un client anonyme pouvait faire garder au tableau de bord un téléversement volumineux ou sans fin en mémoire.
Un nouveau AuthBodyLimitMiddleware en ASGI pur impose un budget de 64 Kio aux POST publics sous /auth/ :
- si
Content-Lengthdépasse la limite, il répond 413 avant de lire le moindre octet du corps ; - il compte aussi les octets qu’il reçoit réellement, donc un corps en transfert fragmenté (chunked), ou un corps dont le
Content-Lengthest sous-estimé, est coupé à la même limite.
Le middleware est enregistré le plus à l’intérieur — aucun middleware HTTP externe ne lit les corps de requête — donc la limite s’applique avant que quoi que ce soit ne mette en mémoire tampon. Les routes authentifiées, les téléversements et les WebSockets sont inchangés.
Ce que votre propre déploiement doit changer
Les trois correctifs sont un comportement côté serveur ; il n’y a aucun code à écrire. La seule chose qui vaut la peine d’être vérifiée est dashboard.trusted_proxies : seuls les pairs qui y figurent (plus le loopback, toujours de confiance) sont autorisés à fournir X-Forwarded-For. Les entrées sans borne comme *, 0.0.0.0/0 et ::/0 sont rejetées.
dashboard:
public_url: "https://dashboard.example.com/hermes"
trusted_proxies:
- "172.20.0.0/24"
Si vous ne listez pas votre reverse proxy, les clients derrière lui ne peuvent pas présenter leur propre adresse, donc chaque client derrière ce proxy partage un même budget de connexion par mot de passe et les événements d’audit enregistrent l’adresse du proxy. Cela pèche du côté sûr — un compartiment partagé vaut mieux qu’un compartiment falsifiable — mais rend la limite de débit imprécise, alors préférez une IP de proxy exacte, ou un réseau borné réservé au proxy.
Il existe un moyen sommaire mais efficace de vérifier : frappez le point de connexion onze fois et attendez-vous à un 429 à la onzième. Puis recommencez avec un X-Forwarded-For forgé à chaque requête ; la onzième devrait toujours être un 429. Si la seconde série passe sans encombre, votre instance a encore l’ancien comportement.
Statut de release
Les trois correctifs sont sur main et dans aucune étiquette publiée pour l’instant (la release la plus récente est v0.21.5, tag v2026.9.24). Les installations Git les obtiennent en tirant le dernier main ; les images Docker et les déploiements hébergés les obtiennent quand le prochain tag est construit. Vous pouvez confirmer ce que vous exécutez avec hermes version, et le tableau complet de la connexion au tableau de bord et de la configuration du proxy se trouve dans la référence de commande dashboard.
Le schéma derrière les trois
Ces trois correctifs partagent une même forme : une entrée non authentifiée traitée comme si elle était digne de confiance. Une adresse source déclarée par le client traitée comme une identité, une chaîne soumise par le client traitée comme un champ borné, un corps envoyé par le client traité comme d’une taille raisonnable. La même classe apparaît dans tout service exposé à internet, et le correctif est toujours le même : ne faire confiance qu’à ce que votre propre côté a observé, et mettre un plafond sur tout ce qui arrive.
Pour être complet, le même jour a aussi apporté des correctifs sur les frontières d’identifiants et de fichiers — l’export de profil qui pouvait embarquer vos secrets et la frontière d’accès entre profils valent la peine d’être lus en parallèle de ceux-ci.