NoSQL injection, explained
No string concatenation, no quotes to escape, still injectable. How a JSON body becomes a MongoDB query operator, and the three lines of code that stop it.
- security
- vibe coding
- engineering
The reassuring thing people believe about MongoDB is that it cannot be injected,
because there is no query string to break out of. There are no quotes to escape
and no -- to comment out the rest of a statement.
That is true and it is not the point. The query is a document, the client sends a document, and MongoDB’s query language treats certain keys as operators. Injection does not need string concatenation. It needs a place where attacker data lands in a position the language interprets, and a JSON body dropped straight into a filter is exactly that place.
It is CWE-943, and OWASP tests it as WSTG-INPV-05.
The login bypass
The vulnerable code is the one every tutorial shows:
// Vulnerable. req.body values are placed into the filter as-is.
app.post("/api/login", async (req, res) => {
const { username, password } = req.body;
const user = await db.collection("users").findOne({ username, password });
if (!user) return res.status(401).json({ error: "Invalid credentials" });
res.json({ token: sign(user) });
});
Send the expected shape and it behaves. Send this instead:
{ "username": "admin", "password": { "$ne": null } }
The filter that reaches MongoDB is
{ username: "admin", password: { $ne: null } }, which reads as “the admin
whose password is not null.” That is the admin. The server hands back a session.
Variations on the same idea:
{"username": {"$ne": null}, "password": {"$ne": null}}returns the first user in the collection, no username needed.{"username": {"$regex": "^a"}}enumerates users by prefix, one character at a time, which is account enumeration with a wildcard.{"$gt": ""}works the same way as$neon string fields, and it slips past filters that only blocked$ne.{"password": {"$regex": "^a"}}extracts a stored plaintext password character by character, which is a blind extraction identical in spirit to blind SQL injection.
Notice that hashing the password fixes only the last one. The bypass at the top
works exactly as well against a bcrypt column, because $ne: null never
compares anything: it asks whether a field is present.
The severe version: server-side JavaScript
MongoDB’s $where operator evaluates a JavaScript expression per document, and
$function and mapReduce do the same. If any part of that expression comes
from a request, the attacker is running code inside the query engine:
// Vulnerable in a much worse way.
const items = await db.collection("items")
.find({ $where: `this.name === '${req.query.name}'` })
.toArray();
A name of ' || true || ' returns the entire collection. A name containing
a while (true) {} is a denial of service that a query timeout may or may not
catch.
This is also the family that produces real CVEs in shipped products rather than in tutorials. Rocket.Chat’s CVE-2021-22911 was an unauthenticated NoSQL injection reachable through one of its methods, and it was chained into an administrator takeover. The lesson from that writeup is not about Rocket.Chat: it is that a blind NoSQL injection, which returns no data directly, still leaks a bit at a time through whether the query matched, and a bit at a time is enough.
The fix, in three layers
Layer one: make the value a string. The bug is a type confusion before it is
anything else, so the cheapest complete fix is to refuse the wrong type. Use a
schema validator rather than a manual typeof check, so the rule is written
down and reused:
import { z } from "zod";
const Login = z.object({
username: z.string().min(1).max(64),
password: z.string().min(8).max(200),
}).strict();
app.post("/api/login", async (req, res) => {
const parsed = Login.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "invalid body" });
const { username, password } = parsed.data;
// Look up by the identifier only. Never put the secret in the filter.
const user = await db.collection("users").findOne({ username });
const ok = await bcrypt.compare(password, user?.passwordHash ?? DUMMY_HASH);
if (!user || !ok) return res.status(401).json({ error: "Invalid credentials" });
res.json({ token: sign(user) });
});
Two separate improvements are in there. z.string() rejects {"$ne": null}
because an object is not a string, which closes the operator injection. And the
password never appears in the filter at all, which closes the class rather than
the instance: a secret compared in application code cannot be turned into a
query predicate no matter what the attacker sends. The DUMMY_HASH fallback is
there so the two failure paths take the same amount of time, for the reasons in
the enumeration post.
Layer two: sanitize at the driver. Defense in depth for the handlers you
have not audited yet. Mongoose 6 and later ship
sanitizeFilter,
which wraps any object-valued filter input in $eq so it can no longer be read
as an operator:
mongoose.set("sanitizeFilter", true);
For the raw driver or Express generally, express-mongo-sanitize strips keys
beginning with $ or containing . from request payloads. Both are worth
having and neither is a substitute for layer one, because both are blanket
transformations that a future refactor can bypass by reading the body before the
middleware or by building a filter from a source they do not cover.
Layer three: turn off what you do not use. Set
security.javascriptEnabled: false in mongod.conf if your application does
not need $where, $function, or mapReduce. Most do not. This converts the
severe variant above from a code execution bug into an error message, at zero
cost, and it is the same reasoning as disabling XML external entities in a
parser you only use for simple documents.
Also grant per-application database users only the roles they need
(readWrite on one database, not dbAdminAnyDatabase), so an injection that
does succeed is bounded. That is the
default credentials discipline applied
to a database role.
The same bug outside MongoDB
The pattern generalizes to every query language that accepts structured input, which is most of them now:
- PostgREST and Supabase. The filter language lives in the query string, so
a client that can craft its own request can ask for
?select=*and any operator the role is allowed. There is no injection into a query here: the client is the query author by design, and the security boundary is entirely row level security plus column grants. Building a filter server-side from user input and passing it through reproduces the original bug on top of that design. - Elasticsearch. A user-supplied
query_stringclause allows field references and wildcards the caller was not meant to have, which reads other fields of the same document. - GraphQL. Filter arguments passed straight into an ORM
whereclause have the identical shape, with the added detail that a deeply nested filter is also a resource exhaustion vector. - Firestore. Client SDK queries are constrained by security rules, not by your code, so an unconstrained rule is the same failure with different syntax. The Tea app breach is what that looks like at full scale.
The common thread across all of them: when the client can influence the structure of a query rather than only its values, the query language is part of your attack surface, and the only durable defense is to decide the structure server-side and let the client supply nothing but leaves.
What we flag
OpenGrep (app/engines/opengrep.py), our primary SAST engine, matches the
recognizable shapes: $where built from a template literal, a request object
spread into a find or findOne filter, and unvalidated req.body reaching a
Mongoose model method. ESLint’s security plugin (eslint_security.py) adds the
generic non-literal patterns on JavaScript repos.
What no scanner will tell you is whether a validator that exists is actually
strict, since z.any() and z.string() are both “validated.” Read the schema,
not the presence of a schema.
The takeaway
NoSQL injection is not SQL injection with different syntax. It is a reminder that the vulnerability was never really about strings. It was about a boundary where data becomes instruction, and every query language has one, including the ones that market themselves as not having one. Validate the type, keep secrets out of filters, and never let a request body decide the shape of a query.