Mongoose walks into inherited properties when hydrating, setting, and merging, allowing overwrites of built-in functions (denial of service) and shared schema stateGHSA-8332-j7wj-wjw4
HIGHFix: Automattic/mongoose@18b5513GHSA-8332-j7wj-wjw4 is a high-severity (CVSS 8.2) CWE-1321 vulnerability in mongoose. A fix is available for mongoose — see the affected versions and patch details below.
EPSS Exploitation Probability
Probability of exploitation in the next 30 days, from FIRST.org EPSS.
How urgent is this, really
Where this sits among everything scored
Real-World Exposure
How broadly this vulnerability is actually deployed: weekly install volume shows current usage, and reverse-dependency count shows how many other packages break if it stays unpatched.
mongoosenpmDescription
Summary
Three Mongoose code paths copy data into objects by property name without checking whether the property is the object's own or inherited from its prototype:
- Loading a document from the database (
init(), also used byModel.hydrate()) - Setting a dotted path on a document (
$__set, used bynew Model(obj),doc.set()anddoc.$set()) - Merging update documents (
utils.mergeClone, used byfindOneAndReplace(), chained update calls andQuery.prototype.merge())
When a key names an inherited property, such as toString or valueOf, or a document internal such as $__schema, Mongoose writes into the shared object behind that property. The data comes from the attacker.
- Denial of service. For example,
{ "toString": { "call": 1 } }overwritesObject.prototype.toString.call. Mongoose and most other libraries rely onObject.prototype.toString.call(). After the overwrite, every later query in the process throwsTypeError: _toString.call is not a functionuntil the process restarts. This includes queries on unrelated models. - Changing shared schema state. Through a subdocument with
strict: false, a path such asprofile.$__schema.paths.email.validatorsreaches the subdocument's schema, which every document shares. Setting it to[]removes therequiredvalidator for every document in the process.
Mongoose already blocked __proto__, constructor and prototype, so these bugs cannot add properties to Object.prototype itself. They can still overwrite methods and properties of shared built-in functions and of Mongoose's internal objects.
Details
1. Loading documents: init()
if (!schemaType && utils.isPOJO(value)) {
// assume nested object
if (!doc[i]) { doc[i] = {}; ... }
init(self, value, doc[i], opts, path + '.');
}
For a stored field named toString, doc[i] is the inherited Object.prototype.toString. It is truthy, so init() recurses into that function and copies the stored subfields onto it.
This runs for every document Mongoose loads: find(), findOne(), cursors, populated documents and Model.hydrate(). It runs whatever the strict setting, and it applies to top-level and nested paths alike. lean() queries are not affected.
A malicious document can get into the collection in several ways:
- through a model with
strict: false, for examplenew Model(req.body).save(); - through another application or the raw MongoDB driver;
- through any update that writes arbitrary keys.
2. Setting dotted paths: $__set
When it walks a dotted path, $__set read obj[parts[i]] and descended into whatever it found. That included inherited functions, such as Object.prototype.toString on a plain object or Document.prototype.toObject on a subdocument. On subdocuments it also included non-function internals that are prototype getters, such as $__schema, schema, $__middleware and $__schemaTypeOptions. Those point at objects that every document of the model shares.
Attacker-controlled input reaches this path through:
- A
Mixedpath on a default, strict schema:new Model({ "mixed.toString.call": 1 }), ordoc.set({ "mixed.toString.call": 1 }). Nothing needs to be saved. - Any root path on a schema with
strict: false: for exampledoc.set('toString.call', 1). - A subdocument with
strict: false, when the application passes a user-controlled path todoc.set(path, value): for exampledoc.set('profile.$__schema.paths.email.validators', []).
3. Merging updates: utils.mergeClone
if (typeof to[key] === 'undefined') {
to[key] = clone(fromObj[key]);
} else if (isObject(val)) {
mergeClone(to[key], val); // to[key] may be an inherited built-in
}
to[key] was read without an own-property check, so a key such as toString recursed into Object.prototype.toString. This affects:
Model.findOneAndReplace(filter, replacement). It always merges the replacement into the query's update, so a single call with an untrusted replacement is enough.- Update calls chained onto a query that already has an update. For example
Model.updateOne(filter, { $set: {...} }).updateOne({}, untrustedUpdate), the same pattern withfindOneAndUpdate(), or middleware that callsthis.updateOne(...). Query.prototype.merge()with a source query whose update came from untrusted input.
Proof of concept
const mongoose = require('mongoose');
const { Schema } = mongoose;
// 2. Setting dotted paths: a Mixed path on a default strict schema, nothing saved
const Item = mongoose.model('Item', new Schema({ name: String, meta: {} }));
new Item(JSON.parse('{"meta.toString.call": 1}')); // e.g. new Item(req.body)
console.log(typeof Object.prototype.toString.call); // 'number'
// From here on, every query in the process fails:
// TypeError: _toString.call is not a function
// 1. Loading documents: a stored field named after an Object.prototype method
const Loose = mongoose.model('Loose', new Schema({ name: String }, { strict: false }));
await new Loose(JSON.parse('{"name":"evil","toString":{"call":1}}')).save();
await Loose.findOne({ name: 'evil' }); // any later read of this document
await OtherModel.findOne(); // TypeError: _toString.call is not a function
// 3. Merging updates: a single findOneAndReplace() call with an untrusted replacement
const Doc = mongoose.model('Doc', new Schema({ name: String }));
Doc.findOneAndReplace({}, JSON.parse('{"toString":{"call":1}}'));
console.log(typeof Object.prototype.toString.call); // 'number'
// 2. Setting dotted paths: changing the shared subdocument schema
const User = mongoose.model('User', new Schema({
profile: new Schema({ email: { type: String, required: true } }, { strict: false })
}));
// e.g. JSON-patch style endpoint: doc.set(req.body.path, req.body.value)
new User({ profile: { email: '[email protected]' } }).set('profile.$__schema.paths.email.validators', []);
await new User({ profile: {} }).validate(); // resolves: `required` removed for every document
Impact
-
Denial of service (paths 1, 2 and 3). One request overwrites
Object.prototype.toString.call. After that, every query in the process fails, and so does much other code, until the process restarts. Ways in:new Model(req.body)on any schema with aMixedpath;findOneAndReplace()with an untrusted replacement;- reading one stored document that contains a field such as
{ "toString": { "call": 1 } }.
In the stored-document case, the attacker does not need any write path through Mongoose at the moment of the attack. Any later read of that document triggers the overwrite. If the document is read on startup or by a scheduled job, the application can be kept down.
-
Changes to shared state (path 2,
strict: falsesubdocuments). An attacker who controls the path passed todoc.set(path, value)can change a subdocument schema that every document shares. For example, they can remove validators or change schema options for every later document in the process. -
Properties on built-in functions. More generally, an attacker can attach properties they choose to built-in functions such as
Object.prototype.toString,Object.prototype.valueOfandFunction.prototype.call. Any other code that reads properties of those functions sees the attacker's values.
Affected Packages
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 📦npm | mongoose | all versions | 6.13.12npm install mongoose@6.13.12 |
| 📦npm | mongoose | ≥ 7.0.0&&< 7.8.13 | 7.8.13npm install mongoose@7.8.13 |
| 📦npm | mongoose | ≥ 8.0.0&&< 8.24.7 | 8.24.7npm install mongoose@8.24.7 |
| 📦npm | mongoose | ≥ 9.0.0&&< 9.11.4 | 9.11.4npm install mongoose@9.11.4 |
Detection & mitigation playbook
Open-source dependencyDetect
Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for mongoose, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.
Fix
Update mongoose to 6.13.12 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-8332-j7wj-wjw4 is resolved across your whole dependency graph.
Workarounds
Cap what an attacker can consume: apply request size, rate and timeout limits in front of the affected component, and run it with memory and CPU limits so exhaustion degrades one worker rather than the whole service.
Frequently Asked Questions
Is GHSA-8332-j7wj-wjw4 in your dependencies?
Find it across npm, including transitive dependencies.