Mongoose chooses which discriminator model or schema to use by looking up the discriminator key value (__t by default) in a plain object of registered discriminators, for example Model.discriminators[doc.__t]. That value comes from user documents and query filters. Because the lookup did not check own properties, a value that names a property inherited from Object.prototype resolves to that inherited value instead of a discriminator. Examples are 'constructor', 'toString' and 'hasOwnProperty'.
__t: 'constructor' resolves to the global Object function. Mongoose then calls new Object(doc), which returns the raw user object where a Mongoose document is expected.__t: 'toString' and similar names resolve to built-in functions that throw when used as a constructor or schema.The most serious effect: through bulkWrite() insertOne with skipValidation: true, the raw user object is stored as-is, without schema casting or strict filtering. After that, every query that returns the stored document throws, until the document is removed from the database.
The registries that hold discriminators are plain objects: Model.discriminators, schema.discriminators, and the embedded discriminator registries on document arrays and single nested subdocuments. Several code paths indexed them directly with a value taken from the document or filter:
| Code path | Input |
|---|---|
Model constructor (new Model(doc)) |
document |
Model.create(), insertMany(), bulkWrite(), bulkSave() |
documents |
Hydrating query results (find(), findOne(), etc.) and Model.hydrate() |
stored documents |
Casting query filters, including flat paths such as 'events.__t' and $elemMatch |
query filter |
| Casting updates and array filters | update filter, arrayFilters |
Document array push() / create(), document array and single nested casting |
subdocuments |
| Populate on embedded discriminators | stored subdocuments |
getDiscriminatorByValue() already iterated the registry's own keys safely. But most callers tried the direct lookup first and only fell back to it afterwards, so a truthy inherited value took precedence.
The lookups only read inherited properties. They do not write to Object.prototype or any other shared object, and they do not change in-memory state that affects other requests. This is NOT a prototype pollution vector.
const mongoose = require('mongoose');
const Base = mongoose.model('Base', new mongoose.Schema({ name: String, age: Number }));
Base.discriminator('Child', new mongoose.Schema({ extra: String }));
// Type confusion: the "document" is the raw input object
const doc = new Base({ name: 'x', __t: 'constructor' });
console.log(doc instanceof mongoose.Document); // false
// Schema bypass and persistent poison document via bulkWrite + skipValidation
await Base.bulkWrite([{
insertOne: { document: { name: 'x', age: 'not a number', notInSchema: true, __t: 'constructor' } }
}], { skipValidation: true });
// Stored verbatim: { age: 'not a number', notInSchema: true, __t: 'constructor', ... }
await Base.find(); // throws: Cannot read properties of undefined (reading 'eachPath')
TypeError or a non-document object returned to application code. Ways in include Model.create(req.body), new Model(req.body), Model.find(req.query)-style filters, and pushing user input onto embedded discriminator arrays. The effect is limited to the request carrying the value.bulkWrite({ skipValidation: true }). When an application passes untrusted documents to bulkWrite() insertOne with skipValidation: true, the raw object is stored without casting or strict filtering. Fields the schema would normally strip are persisted, such as an isAdmin that isn't in the schema. Fields with wrong types are also persisted.bulkWrite() path above, the document can get into the collection through another application, the raw MongoDB driver, or any other writer that does not go through Mongoose casting.This issue doesn't let an attacker modify Object.prototype, built-in functions, schemas or other shared in-memory state. The schema bypass requires the application to opt in to skipValidation with untrusted input.
{
"cwe_ids": [
"CWE-843"
],
"github_reviewed": true,
"github_reviewed_at": "2026-11-10T20:57:18Z",
"nvd_published_at": "2026-11-09T23:17:21Z",
"severity": "MODERATE"
}