How to stop NoSQL operator injection in Node.js apps
NoSQL injection in JavaScript stacks is a type-confusion bug: a handler expects a string and reads an object, so a password field of {“$ne”:null} logs an attacker in. How objects reach a query through bodies, query strings and cookies; what Mongoose’s sanitizeFilter does; the two 2026 bypasses of it (the connection-level no-op CVE-2026-125119 and the $nor smuggle CVE-2026-42334); the Xen Orchestra empty-object cookie bypass; and the boundary validation that closes the class.
By The Xalgorix Team
NoSQL injection in JavaScript stacks is usually not a string-escaping bug at all. It is a type-confusion bug: a handler expects a value to be a string and reads it as an object. The canonical example is a login filter built from user input, where a password field that arrives as { "$ne": null } is read by MongoDB as the condition “any password that is not null” — which matches the account and logs the attacker in without a password. This is called operator injection, and it is the most common form of NoSQL injection in Node.js code.
Mongoose ships a defence for exactly this, the sanitizeFilter option, and it drew two advisories in 2026. The most recent, published on 9 November 2026, means that enabling the option at connection level silently never applied it. This post explains how untyped input reaches a query, what sanitizeFilter actually neutralises, the two 2026 gaps in it, and the input-handling pattern that closes the class rather than one operator at a time. It is written for engineers hardening their own code; every claim is checked against the primary advisory or reproduced locally, and both are cited.
How an object reaches a query that expected a string
MongoDB filters are documents, not strings. A value that is a plain string is compared for equality; a value that is an object whose keys begin with $ is an operator expression. The only question that matters for a handler is therefore whether the client can make a value arrive as an object instead of a string. In Node.js there are three everyday routes, and reviewers should treat all three as equivalent sources.
- JSON bodies.
express.json()parses nested objects as nested objects in every Express version. A handler that passesreq.bodyorreq.body.passwordinto a filter lets the client choose the type. - Query-string brackets. Some query-string parsers expand
password[$ne]=xinto a nested object. We cover the Express 4 / 5 difference below — it narrows one route but does not remove the risk. - Structured cookies and other decoders. Any decoder that turns client input into a JavaScript object is a source, not just body parsers. The Xen Orchestra case below is exactly this.
The Express 4 to 5 query-parser change
Express 5 changed the default query parser from “extended” to “simple”, as its migration guide notes. We confirmed the behavioural difference locally against Express 4.22.3 and 5.3.0 with a request of ?username=admin&password[$ne]=x:
// Express 4 (extended / qs parser): bracket is expanded into an object
req.query = { username: "admin", password: { $ne: "x" } }
// Express 5 (simple parser): the bracket stays in the key name
req.query = { username: "admin", "password[$ne]": "x" }So an app on Express 5 that reads only req.query loses the bracket route for operator injection by default. That is worth knowing, but it is not a fix: a JSON body still carries arbitrary nested objects, the parser can be set back to extended with app.set("query parser", "extended"), and plenty of apps parse query strings with qs themselves. Treat the parser default as one closed door in a room with several, not as mitigation you can rely on.
What an injected operator buys an attacker
Operator injection is not only an authentication-bypass trick. The same type confusion gives an attacker read primitives, depending on where the object lands and what the endpoint reveals.
- Condition subversion.
$ne,$gt,$inand friends turn an equality check into a match-anything or match-many condition. In a login filter that is a bypass; in a “fetch my record” filter it can return someone else's. - Blind extraction with
$regex. Where a field holds a secret (a password-reset token, an API key), an attacker who can inject$regexcan confirm one character at a time by watching whether the response changes — a classic boolean oracle. PortSwigger's NoSQL injection guide documents this pattern in detail. - Server-side JavaScript via
$where. If the backend permits it,$whereevaluates JavaScript inside the database, which widens blind extraction and can be chained further. It remains available in MongoDB's current server-side JavaScript surface unless you disable it with--noscriptingorsecurity.javascriptEnabled: false.
A real chain shows how far this goes. In CVE-2021-22911, SonarSource's researchers found an unauthenticated $regex oracle in Rocket.Chat's getPasswordPolicy method that leaked a password-reset token one character at a time, then an authenticated $where primitive in the user-list route to read an admin's hash and 2FA secret, and finally reached code execution through the integrations feature. Rocket.Chat patched it in 3.13.2 (backported to 3.12.4 and 3.11.4) on 14 April 2021. The write-up is worth reading in full: the entry point was a single unvalidated field.
What Mongoose's sanitizeFilter actually does
Mongoose added sanitizeFilter in version 6 (August 2021). Its rule is narrow and worth stating exactly: when a filter value is an object that contains operator keys, Mongoose wraps it in $eq, so { password: { $ne: null } } becomes { password: { $eq: { $ne: null } } }. That asks MongoDB for a document whose password field literally equals the object { $ne: null } — which no real document does — and because the field is typed String in the schema, Mongoose then throws a CastError rather than running it. We reproduced that: with sanitizeFilter on, the { $ne: null } login attempt throws CastError and an unsanitised run of the same filter matches. It is a real, useful control. The logical operators $where, $expr, $text and $jsonSchema are rejected outright.
You enable it per query (Model.find(f).setOptions({ sanitizeFilter: true })) or globally (mongoose.set("sanitizeFilter", true)). When your own code legitimately needs an operator in a filter, you opt that sub-object out with mongoose.trusted(...). The original design write-up has the full API.
sanitizeFilter protects the filter, not the whole query. It does nothing for operators injected into an update document, an aggregation pipeline, or a $where string you build yourself. Scope your mental model to what it covers.Two ways sanitizeFilter was bypassed in 2026
Both are now patched, and both are instructive because they show how a defence that “is on” can still miss.
CVE-2026-125119 — on at connection level, off in practice
Published 9 November 2026 (GHSA-gg4r-jv3p-qx3c, CVSS 7.5), this is a storage mismatch. Setting sanitizeFilter: true on mongoose.connect() or createConnection() stored the flag in Connection.config, but the code that casts a query's conditions read only Connection.options, global options, and per-query options — never Connection.config. So the connection reported the option as enabled while every query ran unsanitised. An app relying on that one line for its NoSQL-injection defence had none. Fixed in 6.13.12, 7.8.13, 8.24.7 and 9.11.4.
This is the dangerous kind of bug: there is no error and no warning. The reassuring thing to take from it is that the global and per-query forms were never affected — which is one reason the fix below prefers explicit per-call enforcement over a single connection flag.
CVE-2026-42334 — operators smuggled inside $nor
Published 5 May 2026 (GHSA-wpg9-53fq-2r8h, CVSS 7.5), this one slips operators past an enabled sanitiser. Mongoose recursed into $and and $or to sanitise their contents, but not into $nor. Because $nor takes an array and the array itself has no $ keys, the operators nested inside it were never reached. Fixed in 6.13.9, 7.8.9, 8.22.1 and 9.1.6.
We confirmed the fix is in place and the shape of the old bug on a patched build (9.12.1). Sanitising { $or: [ { password: { $ne: null } } ] } correctly rewrites the inner condition to $eq; the same structure under $nor is likewise rewritten on the patched version, where on a pre-6.13.9 build it would have passed through untouched:
// mongoose.sanitizeFilter(...) on 9.12.1 (patched)
$or: { $or: [ { password: { $eq: { $ne: null } } } ] } // inner operator neutralised
$nor: { $nor: [ { password: { $eq: { $ne: null } } } ] } // now also neutralisedThe bug class is not specific to MongoDB
Any decoder that turns client input into a structured object can feed a lookup the wrong type. On 10 November 2026 Vates disclosed VSA-2026-062, an actively exploited authentication bypass in Xen Orchestra's REST API. As reported by The Hacker News, a cookie of authenticationToken=j:%7B%7D was parsed into an empty object {} instead of a token string; the token lookup then ran with no effective filter and returned the first stored token, authenticating the attacker as its owner, who could go on to take admin actions and reach root on the hypervisor hosts. Vates fixed it in Xen Orchestra 6.9.1 / 6.10.0 (xo-server 5.211.2 / 5.213.1).
The j: prefix is a tell. Several Node.js decoders — cookie-parser among them — treat a value beginning with j: as JSON and parse the rest. We confirmed that a cookie of j:%7B%22%24ne%22%3Anull%7D is handed to the application as { $ne: null } by current cookie-parser. Whatever the datastore underneath, the lesson is identical to the login handler: an identifier that must be a string was allowed to arrive as an object.
Enforce the type you expected
Every defence that holds comes back to one idea: a value that must be a string is rejected the moment it is not one, before it is used to build a query. Layer these, outermost first.
- Validate at the boundary. Parse requests with a schema validator (Zod, Joi, Ajv,
express-validator) and declare each field's type. An input validator that sayspasswordis a string turns{ $ne: null }into a 400 before any database code runs. This is the control that covers bodies, query strings and decoded cookies alike, because it sits above all of them. - Build filters from named fields. Never pass a request object straight into a query. Write
User.findOne({ username: String(req.body.username) })and compare the password hash in application code, so the filter shape is fixed by you, not by the client. - Keep sanitizeFilter on as defence in depth — globally or per query rather than only at connection level, given CVE-2026-125119 — and stay on a patched Mongoose (≥ 6.13.12 / 7.8.13 / 8.24.7 / 9.11.4, which also carry the
$norfix). Treat it as a backstop behind input validation, not your primary control. - Disable server-side JavaScript (
--noscripting/security.javascriptEnabled: false) unless you genuinely use$where,$functionor$accumulator. It removes the most powerful injected primitive outright. - Watch every decoder, not just body parsers. Cookies, headers and anything with a
j:-style JSON convention are inputs too. Pin their output to the type the consumer expects.
Telling a reachable bug from a theoretical one
Finding an unvalidated field is the start, not the finding. The gap between “this handler passes req.body into a filter” and “an attacker can log in as admin” is full of conditions: the field has to reach a filter (not an already-cast parameter), the schema type has to allow the cast through or be bypassed, and the response has to expose a difference an attacker can observe. Some of those are only decidable by sending the object and watching the behaviour, in a way that is safe against the target.
That observation is where Xalgorix fits: it re-exploits a candidate in a scoped, non-destructive way and keeps the request and response that show the condition was real, rather than flagging every findOne(req.body) as a finding. It is honest about the limit, too — an operator-injection check can confirm a bypass it can reach, but it cannot prove the absence of one behind input it never sees. The durable control is still the boundary validator above; verification tells you which of today's handlers already need it most.
Ready to see it prove a bug?
Start scanning on Cloud →