Habitica est une appli de productivité gamifiée (open source, Node.js). L’endpoint GET /api/v3/groups/:groupId/members?search=... cherche des membres dans un groupe en construisant une regex MongoDB à partir du paramètre search. L’input est bien échappé pour un champ, mais pas pour l’autre (website/server/controllers/api-v3/members.js) :
if (req.query.search) {
const escapedSearch = escapeRegExp(req.query.search); // input nettoyé...
query.$or = [
{ 'profile.name': { $regex: new RegExp(escapedSearch, 'i') } }, // ...bien utilisé ici
{ 'auth.local.username': { $regex: new RegExp(req.query.search, 'i') } }, // mais ici : input BRUT
];
}
escapeRegExp (lodash) neutralise les métacaractères d’une regex. Il est appliqué correctement sur profile.name, mais la ligne du dessous réutilise req.query.search brut au lieu de escapedSearch. Donc sur le champ auth.local.username, tout ce qu’on met dans search est interprété comme une regex. La bonne variable existait déjà deux lignes plus haut, c’est juste la mauvaise qui a été passée.
Concrètement, pour n’importe quel utilisateur authentifié membre du groupe :
-
Énumération des usernames. Là où une recherche littérale ne renvoie rien, un motif regex remonte des membres :
.*renvoie tout le groupe,^adminrévèle qu’un username commence paradmin,(?=.*secret)teste une sous-chaîne. En enchaînant des motifs ancrés on reconstitue les usernames caractère par caractère. -
ReDoS. Un motif comme
(a+)+$déclenche un backtracking catastrophique. La regex étant passée à MongoDB en$regex, c’est le moteur de la base qui l’évalue contre les usernames, et le temps de calcul croît exponentiellement avec la longueur du motif (de quelques dizaines de ms à plusieurs secondes). Une seule requête suffit à faire tourner le CPU de la base dans le vide et à figer la réponse ; le rate limiter (30 req/60 s) n’y change rien, quelques requêtes en parallèle suffisent.
Liens
- CVE : CVE-2026-54461
- Advisory : GHSA-x772-22c9-gq58
- Dépôt : HabitRPG/habitica
- Correctif : commit 7b7dc25 (release v5.48.2)