Habitica is a gamified productivity app (open source, Node.js). The endpoint GET /api/v3/groups/:groupId/members?search=... looks up members in a group by building a MongoDB regex from the search parameter. The input is properly escaped for one field, but not for the other (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) neutralizes a regex’s metacharacters. It is applied correctly on profile.name, but the line below reuses the raw req.query.search instead of escapedSearch. So on the auth.local.username field, anything you put in search is interpreted as a regex. The right variable already existed two lines above; it is simply the wrong one that got passed.
In practice, for any authenticated user who is a member of the group:
-
Username enumeration. Where a literal search returns nothing, a regex pattern surfaces members:
.*returns the whole group,^adminreveals that a username begins withadmin,(?=.*secret)tests for a substring. By chaining anchored patterns you reconstruct usernames character by character. -
ReDoS. A pattern like
(a+)+$triggers catastrophic backtracking. Since the regex is passed to MongoDB as$regex, it is the database engine that evaluates it against usernames, and the compute time grows exponentially with the length of the pattern (from a few tens of ms to several seconds). A single request is enough to spin the database CPU uselessly and freeze the response; the rate limiter (30 req/60 s) does not help, a few parallel requests are enough.
Links
- CVE: CVE-2026-54461
- Advisory: GHSA-x772-22c9-gq58
- Repository: HabitRPG/habitica
- Fix: commit 7b7dc25 (release v5.48.2)