GHSA-8332-J7WJ-WJW4

Vulnerability from github – Published: 2026-11-10 20:56 – Updated: 2026-11-10 20:56
VLAI
Summary
Mongoose walks into inherited properties when hydrating, setting, and merging, allowing overwrites of built-in functions (denial of service) and shared schema state
Details

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:

  1. Loading a document from the database (init(), also used by Model.hydrate())
  2. Setting a dotted path on a document ($__set, used by new Model(obj), doc.set() and doc.$set())
  3. Merging update documents (utils.mergeClone, used by findOneAndReplace(), chained update calls and Query.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 } } overwrites Object.prototype.toString.call. Mongoose and most other libraries rely on Object.prototype.toString.call(). After the overwrite, every later query in the process throws TypeError: _toString.call is not a function until the process restarts. This includes queries on unrelated models.
  • Changing shared schema state. Through a subdocument with strict: false, a path such as profile.$__schema.paths.email.validators reaches the subdocument's schema, which every document shares. Setting it to [] removes the required validator 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 example new 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 Mixed path on a default, strict schema: new Model({ "mixed.toString.call": 1 }), or doc.set({ "mixed.toString.call": 1 }). Nothing needs to be saved. - Any root path on a schema with strict: false: for example doc.set('toString.call', 1). - A subdocument with strict: false, when the application passes a user-controlled path to doc.set(path, value): for example doc.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 with findOneAndUpdate(), or middleware that calls this.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: 'a@b.c' } }).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 a Mixed path;
  • 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: false subdocuments). An attacker who controls the path passed to doc.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.valueOf and Function.prototype.call. Any other code that reads properties of those functions sees the attacker's values.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "npm",
        "name": "mongoose"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "6.13.12"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "npm",
        "name": "mongoose"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "7.0.0"
            },
            {
              "fixed": "7.8.13"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "npm",
        "name": "mongoose"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "8.0.0"
            },
            {
              "fixed": "8.24.7"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "npm",
        "name": "mongoose"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "9.0.0"
            },
            {
              "fixed": "9.11.4"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-125117"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-1321"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-11-10T20:56:16Z",
    "nvd_published_at": "2026-11-09T23:17:22Z",
    "severity": "HIGH"
  },
  "details": "### Summary\n\nThree Mongoose code paths copy data into objects by property name without checking whether the property is the object\u0027s own or inherited from its prototype:\n\n1. Loading a document from the database (`init()`, also used by `Model.hydrate()`)\n2. Setting a dotted path on a document (`$__set`, used by `new Model(obj)`, `doc.set()` and `doc.$set()`)\n3. Merging update documents (`utils.mergeClone`, used by `findOneAndReplace()`, chained update calls and `Query.prototype.merge()`)\n\nWhen 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.\n\n- **Denial of service.** For example, `{ \"toString\": { \"call\": 1 } }` overwrites `Object.prototype.toString.call`. Mongoose and most other libraries rely on `Object.prototype.toString.call()`. After the overwrite, every later query in the process throws `TypeError: _toString.call is not a function` until the process restarts. This includes queries on unrelated models.\n- **Changing shared schema state.** Through a subdocument with `strict: false`, a path such as `profile.$__schema.paths.email.validators` reaches the subdocument\u0027s schema, which every document shares. Setting it to `[]` removes the `required` validator for every document in the process.\n\nMongoose 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\u0027s internal objects.\n\n### Details\n\n#### 1. Loading documents: `init()`\n\n```js\nif (!schemaType \u0026\u0026 utils.isPOJO(value)) {\n  // assume nested object\n  if (!doc[i]) { doc[i] = {}; ... }\n  init(self, value, doc[i], opts, path + \u0027.\u0027);\n}\n```\n\nFor 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.\n\nThis 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.\n\nA malicious document can get into the collection in several ways:\n- through a model with `strict: false`, for example `new Model(req.body).save()`;\n- through another application or the raw MongoDB driver;\n- through any update that writes arbitrary keys.\n\n#### 2. Setting dotted paths: `$__set`\n\nWhen 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.\n\nAttacker-controlled input reaches this path through:\n- **A `Mixed` path on a default, strict schema:** `new Model({ \"mixed.toString.call\": 1 })`, or `doc.set({ \"mixed.toString.call\": 1 })`. Nothing needs to be saved.\n- **Any root path on a schema with `strict: false`:** for example `doc.set(\u0027toString.call\u0027, 1)`.\n- **A subdocument with `strict: false`**, when the application passes a user-controlled path to `doc.set(path, value)`: for example `doc.set(\u0027profile.$__schema.paths.email.validators\u0027, [])`.\n\n#### 3. Merging updates: `utils.mergeClone`\n\n```js\nif (typeof to[key] === \u0027undefined\u0027) {\n  to[key] = clone(fromObj[key]);\n} else if (isObject(val)) {\n  mergeClone(to[key], val);   // to[key] may be an inherited built-in\n}\n```\n\n`to[key]` was read without an own-property check, so a key such as `toString` recursed into `Object.prototype.toString`. This affects:\n\n- **`Model.findOneAndReplace(filter, replacement)`.** It always merges the replacement into the query\u0027s update, so a single call with an untrusted replacement is enough.\n- **Update calls chained onto a query that already has an update.** For example `Model.updateOne(filter, { $set: {...} }).updateOne({}, untrustedUpdate)`, the same pattern with `findOneAndUpdate()`, or middleware that calls `this.updateOne(...)`.\n- **`Query.prototype.merge()`** with a source query whose update came from untrusted input.\n\n### Proof of concept\n\n```js\nconst mongoose = require(\u0027mongoose\u0027);\nconst { Schema } = mongoose;\n\n// 2. Setting dotted paths: a Mixed path on a default strict schema, nothing saved\nconst Item = mongoose.model(\u0027Item\u0027, new Schema({ name: String, meta: {} }));\nnew Item(JSON.parse(\u0027{\"meta.toString.call\": 1}\u0027));  // e.g. new Item(req.body)\nconsole.log(typeof Object.prototype.toString.call); // \u0027number\u0027\n// From here on, every query in the process fails:\n// TypeError: _toString.call is not a function\n```\n\n```js\n// 1. Loading documents: a stored field named after an Object.prototype method\nconst Loose = mongoose.model(\u0027Loose\u0027, new Schema({ name: String }, { strict: false }));\nawait new Loose(JSON.parse(\u0027{\"name\":\"evil\",\"toString\":{\"call\":1}}\u0027)).save();\nawait Loose.findOne({ name: \u0027evil\u0027 });              // any later read of this document\nawait OtherModel.findOne();                         // TypeError: _toString.call is not a function\n```\n\n```js\n// 3. Merging updates: a single findOneAndReplace() call with an untrusted replacement\nconst Doc = mongoose.model(\u0027Doc\u0027, new Schema({ name: String }));\nDoc.findOneAndReplace({}, JSON.parse(\u0027{\"toString\":{\"call\":1}}\u0027));\nconsole.log(typeof Object.prototype.toString.call); // \u0027number\u0027\n```\n\n```js\n// 2. Setting dotted paths: changing the shared subdocument schema\nconst User = mongoose.model(\u0027User\u0027, new Schema({\n  profile: new Schema({ email: { type: String, required: true } }, { strict: false })\n}));\n// e.g. JSON-patch style endpoint: doc.set(req.body.path, req.body.value)\nnew User({ profile: { email: \u0027a@b.c\u0027 } }).set(\u0027profile.$__schema.paths.email.validators\u0027, []);\nawait new User({ profile: {} }).validate();         // resolves: `required` removed for every document\n```\n\n### Impact\n\n- **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:\n  - `new Model(req.body)` on any schema with a `Mixed` path;\n  - `findOneAndReplace()` with an untrusted replacement;\n  - reading one stored document that contains a field such as `{ \"toString\": { \"call\": 1 } }`.\n\n  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.\n- **Changes to shared state (path 2, `strict: false` subdocuments).** An attacker who controls the path passed to `doc.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.\n- **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.valueOf` and `Function.prototype.call`. Any other code that reads properties of those functions sees the attacker\u0027s values.",
  "id": "GHSA-8332-j7wj-wjw4",
  "modified": "2026-11-10T20:56:16Z",
  "published": "2026-11-10T20:56:16Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/Automattic/mongoose/security/advisories/GHSA-8332-j7wj-wjw4"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-125117"
    },
    {
      "type": "WEB",
      "url": "https://github.com/Automattic/mongoose/commit/18b5513dd463731a2caf0d3395ef1d9eb0735f50"
    },
    {
      "type": "WEB",
      "url": "https://github.com/Automattic/mongoose/commit/2a195d19a4dd240f039a0a226614d5804e8187ee"
    },
    {
      "type": "WEB",
      "url": "https://github.com/Automattic/mongoose/commit/3cb702b8d079c3e267f0b10f41673a1590f703c8"
    },
    {
      "type": "WEB",
      "url": "https://github.com/Automattic/mongoose/commit/85cf897105edfeb5f56be42a18a68704434cac82"
    },
    {
      "type": "WEB",
      "url": "https://github.com/Automattic/mongoose/commit/8cee51beead554df1f3d05645edb439c69eb2a9b"
    },
    {
      "type": "WEB",
      "url": "https://github.com/Automattic/mongoose/commit/8eb74cb4a550cbe0225963b07aebacbd999d4b74"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/Automattic/mongoose"
    },
    {
      "type": "WEB",
      "url": "https://github.com/Automattic/mongoose/releases/tag/6.13.12"
    },
    {
      "type": "WEB",
      "url": "https://github.com/Automattic/mongoose/releases/tag/7.8.13"
    },
    {
      "type": "WEB",
      "url": "https://github.com/Automattic/mongoose/releases/tag/8.24.7"
    },
    {
      "type": "WEB",
      "url": "https://github.com/Automattic/mongoose/releases/tag/9.11.4"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Mongoose walks into inherited properties when hydrating, setting, and merging, allowing overwrites of built-in functions (denial of service) and shared schema state"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline

Sightings and recorded dates, grouped by day · UTC

JSON
How durations are calculated

Following the VTEM framework (Black Hat USA 2015), time to patch starts when the vendor or a coordinator was informed, total time to patch at discovery, and disclosure to patch at public disclosure; vendor response time runs from the report to its acknowledgement. Time to exploit and time unpatched start at the earliest discovery or report, falling back to public disclosure. Unpatched time ends at the first recorded patch, or the calculation time when no patch is recorded. Exploitation to patch shows the interval between those two events; a published proof of concept is a public exploit, not exploitation.

Each result shows its source dates. A negative interval means the end event came first; when date-only inputs leave the order open, the result says so. Date-only inputs are approximate. The earliest evidence wins: KEV report dates and sightings are labelled as such. Records describing another vulnerability are listed but never calculated from, and a PoC alone does not establish exploitation. Missing patch evidence does not prove that no patch exists. Filters below change the event list only.

Events included in the timeline

Loading timeline…


    Loading…

    Loading…

    Loading…

    Related by attack behaviour

    Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.