prototype-pollutionNo prior report
PROTOTYPE_POLLUTION
Schema.prototype.virtual (lib/schema.js:2706-2711) walks a dotted virtual name into this.tree with parts.reduce, so a name whose first segment is __proto__ reads tree['__proto__'] -- Object.prototype -- and writes a VirtualType onto it. Schema.add and Schema.path, the siblings that take the same dotted names, refuse the segment with 'Cannot set special property' (schema.js:812, 1314, 1337, via utils.specialProperties); virtual() and the schema-level virtuals option are the two doors without that guard.
const { Schema } = require('mongoose'); new Schema().virtual(JSON.parse('"__proto__.polluted"'), 1); typeof ({}).polluted -> object. That is the VirtualType written onto Object.prototype; new Schema().path('__proto__.x', String) on the same install throws MongooseError: Cannot set special property `__proto__` on a schema
- Verified by running
- Node v22.14.0 against mongoose@9.9.5 installed fresh with --ignore-scripts, alone. CONTROL FIRST: new Schema().virtual('plain.x', 1) added nothing to Object.prototype and ({}).x stayed undefined. Then virtual('__proto__.polluted', 1) added ['polluted'] to Object.getOwnPropertyNames(Object.prototype) and ({}).polluted became a VirtualType. new Schema({}, { virtuals: { '__proto__.viaOption': { get() {} } } }) added ['viaOption']. THE SIBLINGS IN THE SAME RUN: Schema.path('__proto__.viaPath', String) and Schema.add({ '__proto__.viaAdd': String }) both threw 'Cannot set special property `__proto__` on a schema' and added nothing. FIXED-SIDE CONTROL: inserting one line before schema.js:2706, `if (parts.some(p => utils.specialProperties.has(p))) throw new MongooseError(...)` -- the guard add() already uses -- turned the witness into that same MongooseError with Object.prototype untouched and ({}).polluted undefined; the control plain.x still passed; restoring the published file restored the pollution.
- Checked against
- mongoose 9.9.5, the current release (dist-tags.latest on 2026-09-10). Checked 2026-09-10.
- Note
- Found 2026-09-10 by the fourth probe sweep, the framework ecosystem (servers, body parsers, sessions, auth, ORMs and drivers, queues, loggers, validators, fs and archive tools, HTML/markdown, templating): the four package-level probes over 1,038 installed packages, 10 proven, 1,109 clean, 2,124 abstained, 49 timed out; and the reading finders over the same 1,038 packages, 20,139 files, 37 hits, 11 of them on packages already in this file. Then re-installed fresh at the current release, alone, and re-run. THE MAINTAINERS' OWN PRECEDENT IS WHAT MAKES THIS A VULNERABILITY IN THEIR TERMS RATHER THAN OURS: CVE-2022-2564 (GHSA-f825-f98c-gj3g, 'Prototype pollution via Schema.path', fixed 6.4.6) and CVE-2022-24304 (GHSA-h8hf-x3f4-xwgp, the schema object, fixed 6.4.6) were accepted and fixed for exactly this input through path() and add(), and the fix is the specialProperties guard those two now carry. virtual() takes the same dotted path and was not given it; neither was the `virtuals` schema option, which is documented as an alias for .virtual (schema.js:90) and pollutes the same way. THE FAIR ARGUMENT AGAINST, stated plainly: a virtual name is developer-authored schema code, not a request body, and the 2026 advisory GHSA-664h-wqgq-64gw (update casting, patched in 9.7.2 and closed in this 9.9.5) is the one whose input really is user-controlled. So this is the shape the maintainers have already called a vulnerability twice, reachable only where an application builds schemas from untrusted names. The probe found it as 1 of 13,420 driven calls. NOT REPORTED UPSTREAM: Automattic/mongoose issues searched for 'virtual __proto__' and 'virtual prototype pollution' return nothing on point; the three prototype-pollution advisories name path(), the schema object and _castUpdate, not virtual(). REPRODUCE REWRITTEN 2026-09-11 to the exact string the expression renders, from running the generated witness against a fresh install: typeof renders as the bare word; the commentary is moved after the value.
stateful-matcherNo prior reportNot a vulnerability
STATEFUL_MATCHER_DIVERGENCE
lib/helpers/populate/modelNamesFromRefPath.js:10 declares hasNumericPropRE with the g flag and line 30 uses it only as hasNumericPropRE.test(populatedPath), so the branch that rewrites a refPath for a numerically-indexed populate path (a.1.b -> a.1.c) runs on every other identical call: the second call falls through to mpath.get(refPath) and returns the model names of every array element instead of the indexed one.
const f = require('mongoose/lib/helpers/populate/modelNamesFromRefPath'); const doc = { a: [{ c: 'M1' }, { c: 'M2' }] }; [1,2,3].map(() => JSON.stringify(f('a.c', doc, 'a.1.b', null, null))) -> ["[\"M2\"]","[\"M1\",\"M2\"]","[\"M2\"]"]. Three consecutive calls with the same arguments answer M2, then M1 and M2, then M2
- Verified by running
- Node v22.14.0 against mongoose@9.9.5 installed fresh, alone. Three calls in one process with doc {a:[{c:'M1'},{c:'M2'}]}, refPath 'a.c', populatedPath 'a.1.b': ["M2"], then ["M1","M2"], then ["M2"]. CONTROL in the same program: populatedPath 'a.b', which has no numeric segment and never enters the branch, answered ["M1","M2"] on all three calls. FIXED-SIDE CONTROL: removing the g flag on line 10 gave ["M2"] three times with the control unchanged; restoring the file restored the alternation.
- Checked against
- mongoose 9.9.5, the current release (dist-tags.latest on 2026-09-10). Checked 2026-09-10.
- Note
- Found 2026-09-10 by the fourth probe sweep, the framework ecosystem (servers, body parsers, sessions, auth, ORMs and drivers, queues, loggers, validators, fs and archive tools, HTML/markdown, templating): the four package-level probes over 1,038 installed packages, 10 proven, 1,109 clean, 2,124 abstained, 49 timed out; and the reading finders over the same 1,038 packages, 20,139 files, 37 hits, 11 of them on packages already in this file. Then re-installed fresh at the current release, alone, and re-run. The content-disposition shape, one call deep: a match on 'a.1.b' leaves lastIndex at 4, the next test on the same five-character string starts past the '.1.' and fails, and the helper answers as if the path had no index. THE FUNCTION IS ON THE POPULATE PATH: getModelsMapForPopulate.js:256, 329, 446, 471 and 476 and virtualType.js:184 call it with options.path as populatedPath, so Model.populate(doc, { path: 'a.1.b' }) against a refPath is the public surface; the same populate issued twice in one process gets two different model lists, and the second one is the one that would look up documents in models the index never pointed at. The split on line 31 uses the same regex but String.prototype.split ignores lastIndex, which is why only the test diverges. The fix is deleting the g flag, which nothing in the file needs. NOT REPORTED UPSTREAM: Automattic/mongoose issues mention neither hasNumericPropRE nor lastIndex; #12066 (2022, closed) is about refPath in sub-documents and does not describe alternation. REPRODUCE REWRITTEN 2026-09-11 to the exact string the expression renders, from running the generated witness against a fresh install: the array of JSON strings is what the expression returns and JSON.stringify prints.