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.
| Package | Affected versions | Patched versions |
|---|---|---|
mongoosenpm | >= 8.0.0-rc0, < 8.9.5 | 8.9.5 |
mongoosenpm | >= 7.0.0-rc0, < 7.8.4 | 7.8.4 |
mongoosenpm | < 6.13.6 | 6.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/Mongoosev5Range: 6.0.0
Patches
164a9f9706f24fix: disallow nested $where in populate match
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- github.com/advisories/GHSA-m7xq-9374-9rvxghsaADVISORY
- github.com/advisories/GHSA-vg7j-7cwx-8wgwghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-23061ghsaADVISORY
- github.com/Automattic/mongoose/blob/master/CHANGELOG.mdghsaWEB
- github.com/Automattic/mongoose/commit/64a9f9706f2428c49e0cfb8e223065acc645f7bcghsaWEB
- github.com/Automattic/mongoose/compare/6.13.5...6.13.6ghsaWEB
- github.com/Automattic/mongoose/compare/7.8.3...7.8.4ghsaWEB
- github.com/Automattic/mongoose/compare/8.9.4...8.9.5ghsaWEB
- github.com/Automattic/mongoose/releases/tag/6.13.6ghsaWEB
- github.com/Automattic/mongoose/releases/tag/7.8.4ghsaWEB
- github.com/Automattic/mongoose/releases/tag/8.9.5ghsaWEB
- www.npmjs.com/package/mongooseghsaWEB
News mentions
0No linked articles in our index yet.