VYPR
Critical severityNVD Advisory· Published Jan 15, 2025· Updated Jan 15, 2025

CVE-2025-23061

CVE-2025-23061

Description

Mongoose before 8.9.5 allows search injection via nested $where filters in populate() match, due to incomplete fix for CVE-2024-53900.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Mongoose before 8.9.5 allows search injection via nested $where filters in populate() match, due to incomplete fix for CVE-2024-53900.

Vulnerability

Details

CVE-2025-23061 is a search injection vulnerability in Mongoose, a MongoDB ODM for Node.js, affecting versions before 8.9.5 [1]. The issue stems from an incomplete fix for CVE-2024-53900, which attempted to block the use of $where filters in populate() match conditions [2]. The original fix only checked the top-level $where key, but did not recursively inspect nested objects, allowing attackers to bypass the restriction by embedding $where within deeper structures [3].

Exploitation

An attacker can exploit this by providing a crafted populate() match object that contains a nested $where filter. For example, a match like { "nested": { "$where": "malicious JavaScript" } } would pass the shallow validation and be executed by MongoDB. This requires the application to accept user-supplied match criteria for populate operations, which is common in search or filtering features. No authentication is required if the endpoint is publicly accessible.

Impact

Successful exploitation allows an attacker to inject arbitrary MongoDB $where clauses, which can execute JavaScript expressions on the database server. This can lead to unauthorized data access, data manipulation, or potentially server-side code execution, depending on the database configuration and the injected payload.

Mitigation

The vulnerability is fixed in Mongoose version 8.9.5 [3]. Users should upgrade immediately. The fix introduces a recursive throwOn$where function that checks all levels of the match object for $where keys [3]. As noted in the NVD entry [4], this is an incomplete fix for CVE-2024-53900. No workaround is available; upgrading is the only recommended action.

AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
mongoosenpm
>= 8.0.0-rc0, < 8.9.58.9.5
mongoosenpm
>= 7.0.0-rc0, < 7.8.47.8.4
mongoosenpm
< 6.13.66.13.6

Affected products

3
  • osv-coords2 versions
    >= 6.0.0, < 6.13.6+ 1 more
    • (no CPE)range: >= 6.0.0, < 6.13.6
    • (no CPE)range: >= 8.0.0-rc0, < 8.9.5
  • mongoosejs/Mongoosev5
    Range: 6.0.0

Patches

1
64a9f9706f24

fix: disallow nested $where in populate match

https://github.com/Automattic/mongooseValeri KarpovJan 13, 2025via ghsa
2 files changed · +40 22
  • lib/helpers/populate/getModelsMapForPopulate.js+23 18 modified
    @@ -182,15 +182,7 @@ module.exports = function getModelsMapForPopulate(model, docs, options) {
         if (hasMatchFunction) {
           match = match.call(doc, doc);
         }
    -    if (Array.isArray(match)) {
    -      for (const item of match) {
    -        if (item != null && item.$where) {
    -          throw new MongooseError('Cannot use $where filter with populate() match');
    -        }
    -      }
    -    } else if (match != null && match.$where != null) {
    -      throw new MongooseError('Cannot use $where filter with populate() match');
    -    }
    +    throwOn$where(match);
         data.match = match;
         data.hasMatchFunction = hasMatchFunction;
         data.isRefPath = isRefPath;
    @@ -469,15 +461,7 @@ function _virtualPopulate(model, docs, options, _virtualRes) {
         data.match = match;
         data.hasMatchFunction = hasMatchFunction;
     
    -    if (Array.isArray(match)) {
    -      for (const item of match) {
    -        if (item != null && item.$where) {
    -          throw new MongooseError('Cannot use $where filter with populate() match');
    -        }
    -      }
    -    } else if (match != null && match.$where != null) {
    -      throw new MongooseError('Cannot use $where filter with populate() match');
    -    }
    +    throwOn$where(match);
     
         // Get local fields
         const ret = _getLocalFieldValues(doc, localField, model, options, virtual);
    @@ -749,3 +733,24 @@ function _findRefPathForDiscriminators(doc, modelSchema, data, options, normaliz
     
       return modelNames;
     }
    +
    +/**
    + * Throw an error if there are any $where keys
    + */
    +
    +function throwOn$where(match) {
    +  if (match == null) {
    +    return;
    +  }
    +  if (typeof match !== 'object') {
    +    return;
    +  }
    +  for (const key of Object.keys(match)) {
    +    if (key === '$where') {
    +      throw new MongooseError('Cannot use $where filter with populate() match');
    +    }
    +    if (match[key] != null && typeof match[key] === 'object') {
    +      throwOn$where(match[key]);
    +    }
    +  }
    +}
    
  • test/model.populate.test.js+17 4 modified
    @@ -4195,21 +4195,34 @@ describe('model: populate:', function() {
             const parent = await Parent.create({ name: 'Anakin', child: child._id });
     
             await assert.rejects(
    -          () => Parent.findOne().populate({ path: 'child', match: { $where: 'console.log("oops!");' } }),
    +          () => Parent.findOne().populate({ path: 'child', match: () => ({ $where: 'typeof console !== "undefined" ? doesNotExist("foo") : true;' }) }),
               /Cannot use \$where filter with populate\(\) match/
             );
             await assert.rejects(
    -          () => Parent.find().populate({ path: 'child', match: { $where: 'console.log("oops!");' } }),
    +          () => Parent.find().populate({ path: 'child', match: () => ({ $where: 'typeof console !== "undefined" ? doesNotExist("foo") : true;' }) }),
               /Cannot use \$where filter with populate\(\) match/
             );
             await assert.rejects(
    -          () => parent.populate({ path: 'child', match: { $where: 'console.log("oops!");' } }),
    +          () => parent.populate({ path: 'child', match: () => ({ $where: 'typeof console !== "undefined" ? doesNotExist("foo") : true;' }) }),
               /Cannot use \$where filter with populate\(\) match/
             );
             await assert.rejects(
    -          () => Child.find().populate({ path: 'parent', match: { $where: 'console.log("oops!");' } }),
    +          () => Child.find().populate({ path: 'parent', match: () => ({ $where: 'typeof console !== "undefined" ? doesNotExist("foo") : true;' }) }),
               /Cannot use \$where filter with populate\(\) match/
             );
    +        await assert.rejects(
    +          () => Child.find().populate({ path: 'parent', match: () => ({ $or: [{ $where: 'typeof console !== "undefined" ? doesNotExist("foo") : true;' }] }) }),
    +          /Cannot use \$where filter with populate\(\) match/
    +        );
    +        await assert.rejects(
    +          () => Child.find().populate({ path: 'parent', match: () => ({ $and: [{ $where: 'typeof console !== "undefined" ? doesNotExist("foo") : true;' }] }) }),
    +          /Cannot use \$where filter with populate\(\) match/
    +        );
    +
    +        class MyClass {}
    +        MyClass.prototype.$where = 'typeof console !== "undefined" ? doesNotExist("foo") : true;';
    +        // OK because sift only looks through own properties
    +        await Child.find().populate({ path: 'parent', match: () => new MyClass() });
           });
     
           it('multiple source docs', function(done) {
    

Vulnerability mechanics

Root cause

"The application failed to recursively validate the `match` object in `populate()` calls, allowing nested `$where` operators to bypass security checks."

Attack vector

An attacker can trigger this search injection by providing a crafted nested object containing a `$where` operator within the `match` option of a `populate()` call. Because the previous validation only checked the top level of the `match` object, nested `$where` operators (e.g., inside `$or` or `$and` arrays) were able to bypass the filter [patch_id=28408]. This allows the execution of arbitrary JavaScript via the `$where` operator in the database query context.

Affected code

The vulnerability affects `lib/helpers/populate/getModelsMapForPopulate.js` within the `getModelsMapForPopulate` and `_virtualPopulate` functions. These functions were previously using an insufficient check to prevent the use of `$where` filters in `populate()` operations [patch_id=28408].

What the fix does

The patch introduces a new helper function `throwOn$where` that recursively traverses the `match` object to detect any occurrence of the `$where` key [patch_id=28408]. This replaces the previous, shallow implementation that failed to inspect nested structures. By enforcing this check across all levels of the `match` object, the fix prevents the injection of `$where` operators that were previously overlooked [patch_id=28408].

Preconditions

  • configThe application must use Mongoose versions prior to 8.9.5.
  • inputThe application must allow user-controlled input to influence the `match` option of a `populate()` query.

Generated on May 11, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

12

News mentions

0

No linked articles in our index yet.