L31 · NoSQL injection (MongoDB)
Module 5: Web Application Security · ~20 min · Lesson 31 of 71
🎯 What you'll learn
- Why "MongoDB has no SQL" doesn't mean "no injection": the attacker sends structure, not quotes
- Operator injection (
{"$ne": null}),$regexand$whereattacks, and what each one gives an attacker - The fix in layers: validate types at the edge, build queries from typed values, switch off server-side JavaScript
- Vulnerable vs fixed code in Node (the MongoDB driver) and Java (Spring Data MongoDB)
🔥 Why this matters
Your password-reset endpoint takes {"token": "...", "newPassword": "..."} and runs
users.findOne({resetToken: token}). Someone sends "token": {"$ne": null} instead of a string.
MongoDB returns the first user with any pending reset, and the attacker sets their password. There's
no quote and no OR 1=1, so a SQL-focused WAF rule sees nothing. In 2021, a NoSQL injection in a
pre-auth method of the Rocket.Chat chat server (CVE-2021-22911) let researchers recover users'
reset tokens with $regex and take over accounts. Chained with a second injection, it ended in
admin access and code running on the server.
1. Same bug, different shape
SQL injection (L30) happens when attacker data is pasted into a query string and part of it gets read as code. MongoDB queries aren't strings: they're documents (objects) that your code builds and the driver sends as BSON. So there's usually nothing to "break out" of. Instead, the attacker changes the type of a value.
SQL (L30): the query is a STRING
"SELECT * FROM users WHERE token = '" + input + "'"
input = ' OR '1'='1 ──▶ a quote ends the data,
the rest is read as SQL
MongoDB: the query is a DOCUMENT
{ resetToken: input }
input = "tok-A9f2" ──▶ a value: equality match
input = {"$ne": null} ──▶ an OPERATOR: "not null"
NoSQL injection is when attacker input changes the logic of a NoSQL query. In MongoDB the most
common form is operator injection: a field your code expects to be a string arrives as an object
whose keys are query operators (they start with $). Escaping quotes does nothing here, because
there are no quotes to escape. The defence is about types.
Where do objects sneak in? Anywhere your framework turns text into structure:
- JSON bodies, always.
express.json(), Jackson and every other JSON parser happily produce objects for any field. - Query strings with bracket syntax. Express 4's default "extended" parser (the
qslibrary) turns?accountId[$ne]=xinto{accountId: {$ne: "x"}}. Express 5 switched the default to a "simple" parser that doesn't, so this one depends on your version and settings. - Cookies, sometimes. The
cookie-parsermiddleware parses values that start withj:as JSON.
2. Operator injection: what each operator buys
Here's the reset attack step by step:
attacker reset API (Express) MongoDB
│ 1. POST /v1/auth/reset │ │
│ {"token":{"$ne":null}, │ │
│ "newPassword":"Pwn3d!"} │ │
│ ───────────────────────────▶│ 2. express.json(): │
│ │ token is an OBJECT │
│ │ 3. findOne({ │
│ │ resetToken: token})│
│ │ ─────────────────────▶│
│ │ 4. first user whose │
│ │ token is not null │
│ │ ◀─────────────────────│
│ 5. 200 "password changed" │ │
│ ◀───────────────────────────│ │
A handful of operators do most of the damage:
| Injected value | Means | What the attacker gets |
|---|---|---|
{"$ne": null} or {"$gt": ""} |
"anything" | Login or token checks pass; filters match every document |
{"$in": ["u-1", "u-2"]} |
"any of these" | Many records at once instead of one |
{"$regex": "^a"} |
"starts with a" | A yes/no oracle to extract secrets one character at a time |
{"$exists": true} |
"field is present" | Bypasses equality checks on optional fields |
The worst case is code that passes a whole request object as the filter, such as
find(req.query) or find(req.body). Now the attacker writes your query: any field, any operator.
In a statements API that means ?ownerId[$ne]=x returns everyone's statements, which is broken
object-level authorization (L34, L40) delivered through injection.
3. $where and $regex: when a value becomes code or a pattern
$where runs a JavaScript expression on the server for every document it scans. If you build
that expression by concatenating input, you're back to classic string injection, just in JavaScript:
code: { $where: "this.nickname == '" + nick + "'" }
nick = x' || 'a'=='a
runs: this.nickname == 'x' || 'a'=='a' ──▶ true for ALL docs
nick = x' || sleep(5000) || '
runs: ... sleep(5000) ... ──▶ 5 s per document
Since MongoDB 2.4, $where code can't reach the db object, so it can't read other collections
directly. It can still match every document, leak data through timing, and pin the server's CPU.
MongoDB 8.0 deprecated $where, $function and $accumulator, and you can switch server-side
JavaScript off entirely (deep dive 2). Treat any $where in your codebase as a finding.
$regex turns a value into a pattern. If the attacker controls the whole pattern, they get two
things: a blind oracle (^a, ^ab, ^abc..., see deep dive 1) and ReDoS, a regular expression
that takes exponential time, like ^(a+)+$ against a long string. If you need "starts with" search
on user text, escape the text first and cap its length.
4. The fix, in layers
┌────────────────────────────────────────────────────┐
│ Kong: request schema says these fields are strings │ nice to have
├────────────────────────────────────────────────────┤
│ Service edge: validate type, length and format of │ MUST
│ every field (typed DTO / schema), reject the rest │
├────────────────────────────────────────────────────┤
│ Query code: build filters from typed values, wrap │ MUST
│ in $eq, never find(req.body), never build $where │
├────────────────────────────────────────────────────┤
│ MongoDB: least-privilege user, server-side JS off, │ backstop
│ $jsonSchema validators on collections │
└────────────────────────────────────────────────────┘
- Validate types at the edge. Every field gets a declared type, length and format, and anything
else is rejected with 400. In Node use a schema library (zod, Joi, ajv); in Java bind to a typed
DTO, because Jackson refuses to put a JSON object into a
Stringfield. - Build queries from typed values. Wrapping a value in
$eq({resetToken: {$eq: token}}) tells MongoDB to compare literally, so an object can only match an identical object. Mongoose has a global switch for this:mongoose.set("sanitizeFilter", true)(Mongoose 6 and later). - No string-built queries. No
$where, no JSON strings glued together forBasicQuery, no SpEL with user input in@Query(Spring Data MongoDB had a SpEL injection bug there, CVE-2022-22980, fixed in 3.3.5 and 3.4.1). - Sanitizers are defence in depth. Middleware such as
express-mongo-sanitizestrips keys that start with$. It helps, but stripping is a deny-list, and it doesn't fix the type: check that it supports your Express version before relying on it.
At the gateway, a JSON-schema check (Kong's Request Validator plugin, an Enterprise/Konnect plugin)
can reject "token": {...} before it reaches the service. It's a useful early filter, but the service
must still validate: not every caller comes through Kong.
🧪 Worked example: the reset endpoint in Node and Java
Node, vulnerable (Express + the MongoDB Node driver):
app.post("/v1/auth/reset", async (req, res) => {
const { token, newPassword } = req.body;
const user = await users.findOne({
resetToken: token, // BUG: may be {"$ne": null}
resetExpires: { $gt: new Date() },
});
if (!user) return res.status(400).json({ error: "bad token" });
await setPassword(user._id, newPassword);
res.json({ ok: true });
});
Node, fixed: a strict schema at the edge, $eq in the query.
const { z } = require("zod");
const ResetBody = z.object({
token: z.string().regex(/^[A-Za-z0-9_-]{43}$/),
newPassword: z.string().min(12).max(128),
}).strict(); // unknown fields: rejected
app.post("/v1/auth/reset", async (req, res) => {
const parsed = ResetBody.safeParse(req.body);
if (!parsed.success) return res.status(400).end();
const { token, newPassword } = parsed.data;
const user = await users.findOne({
resetToken: { $eq: token }, // literal compare, belt+braces
resetExpires: { $gt: new Date() },
});
// ... same as before
});
Java, vulnerable: two patterns that show up in Spring services.
// 1. Untyped body: the value can be a Map like {"$ne": null}
@PostMapping("/v1/auth/reset")
ResponseEntity<?> reset(@RequestBody Map<String, Object> body) {
Query q = Query.query(Criteria.where("resetToken")
.is(body.get("token")) // BUG: Map becomes an operator
.and("resetExpires").gt(Instant.now()));
User u = mongoTemplate.findOne(q, User.class);
// ...
}
// 2. A query glued together as a string
Query q = new BasicQuery(
"{ $where: \"this.nickname == '" + nick + "'\" }"); // BUG
Java, fixed: a typed, validated DTO, and queries built from values, never strings.
record ResetRequest(
@NotNull @Pattern(regexp = "[A-Za-z0-9_-]{43}") String token,
@NotNull @Size(min = 12, max = 128) String newPassword) {}
@PostMapping("/v1/auth/reset")
ResponseEntity<?> reset(@Valid @RequestBody ResetRequest r) {
Query q = Query.query(Criteria.where("resetToken").is(r.token())
.and("resetExpires").gt(Instant.now()));
// ...
}
interface UserRepo extends MongoRepository<User, String> {
List<User> findByNickname(String nickname); // value bound as
} // a BSON string
If a caller sends {"token": {"$ne": null}} now, Jackson can't turn an object into a String.
Spring answers 400 before your code runs. (Jackson does coerce scalars by default, so 123 becomes
"123"; the @Pattern check catches that.) And whatever the type, a real reset token should be
stored only as a hash, like the session IDs in L22: then even a leaked value isn't usable.
🛠 Try it (optional, ~5 min)
No install: watch a JSON parser hand your code an object where you expected a string.
node -e 'const b = JSON.parse(process.argv[1]);
console.log(typeof b.token, b.token)' '{"token":{"$ne":null}}'
You'll see object { '$ne': null }. That typeof check is the cheapest fix there is.
With Docker: run a throwaway MongoDB bound to localhost only, then try the three queries.
docker run -d --rm --name l31-mongo \
-p 127.0.0.1:27017:27017 mongo:8.0
mongosh --quiet "mongodb://127.0.0.1:27017/l31"
db.users.insertMany([
{ email: "alice@example.com", resetToken: "tok-A9f2" },
{ email: "bob@example.com", resetToken: null } ])
db.users.findOne({ resetToken: "tok-A9f2" }) // Alice
db.users.findOne({ resetToken: { $ne: null } }) // Alice!
db.users.findOne({ resetToken: { $eq: { $ne: null } } }) // null
The second query is what the vulnerable endpoint runs: no token needed. The third shows $eq treating
the injected object as a plain value that matches nothing. Clean up with docker stop l31-mongo.
🧠 Key takeaways
- MongoDB injection changes the type of a value: a string becomes an operator object like
{"$ne": null}. - JSON bodies always allow objects; Express 4 query strings and
j:cookies can too. - Validate type, length and format at the edge: a zod schema in Node, a typed DTO with Bean Validation in Java.
- Never pass
req.bodyorreq.queryas a filter, and never build$whereor query strings from input. - Backstops:
$eq, MongoosesanitizeFilter, server-side JavaScript off, least-privilege database users.
📖 Words to know
| Term | Meaning |
|---|---|
| NoSQL injection | Attacker input that changes the logic of a query to a non-SQL database |
| Operator injection | Sending an object of $ operators where the code expected a plain value |
| BSON | The binary JSON-like format MongoDB uses for documents and queries |
$where |
A MongoDB operator that runs JavaScript on the server for each document (deprecated in 8.0) |
$eq |
Explicit equality: compares the given value literally, even if it's an object |
| ReDoS | Regular-expression denial of service: a pattern that takes exponential time to evaluate |
sanitizeFilter |
A Mongoose option that wraps $-keyed objects in filters in $eq |
| Typed DTO | A request class whose fields have fixed types, so the parser rejects mismatches |
⏭ Next time
L32 moves from the database to the browser: cross-site scripting, where attacker input becomes JavaScript that runs inside your users' pages.
Blind NoSQL injection: extracting a secret with $regex
Operator injection doesn't always hand the attacker data directly. Often the endpoint only answers
"yes" or "no". That's enough. With $regex, a yes/no answer becomes a way to read a secret one
character at a time, which is blind injection, the same idea as blind SQL injection in L30.
The oracle
Suppose a pre-check endpoint tells the mobile app whether a reset link is still valid:
POST /v1/auth/reset/check {"email": "...", "token": "..."}
──▶ 200 {"valid": true} a document matched
──▶ 200 {"valid": false} nothing matched
The service runs findOne({email, resetToken: token}) without a type check. The attacker requests
a reset for the victim (so a token exists), then asks questions with $regex instead of guessing:
attacker check API MongoDB
│ token {"$regex":"^a"} │ │
│ ────────────────────────────▶│ ────────────────▶│
│ ◀── valid: false ────────────│ ◀── no match ────│
│ token {"$regex":"^b"} │ │
│ ────────────────────────────▶│ ────────────────▶│
│ ◀── valid: true ────────────│ ◀── match! ──────│
│ token {"$regex":"^ba"} ... │ │
│ repeat for each position │ │
▼ │ │
full token "bX7..." ──▶ calls the real reset endpoint
This is how the Rocket.Chat chain from the start of the lesson began: leak a reset token, take over the account, then look for the next bug.
How many requests does it take?
A 43-character URL-safe token has 64 possible symbols per position.
| Strategy | Requests per character | Total for 43 chars |
|---|---|---|
| Try each symbol in turn | up to 64 (avg ~32) | ~1,400 on average |
Binary search with ranges, ^bX[0-9A-M] |
6 (log2 64) | ~260 |
About 260 requests is nothing: no rate limit at all would notice it, and a per-IP limit is dodged by spreading the requests out. That's why rate limiting (L42) slows this down but doesn't fix it.
Worked example: three defences, and what each stops
// A: type check. {"$regex": ...} never reaches the query.
if (typeof token !== "string") return res.status(400).end();
// B: store only a hash of the token (as with session IDs, L22).
const h = crypto.createHash("sha256").update(token).digest("hex");
const user = await users.findOne({ resetTokenHash: { $eq: h } });
// C: rate limit and alert per account, not just per IP.
- A removes the oracle entirely: the right fix.
- B makes the oracle worthless even if A is forgotten somewhere. A
$regexonresetTokenHashcan only leak the hash, and you can't turn a SHA-256 of a 256-bit random token back into the token. (Hashing alone is fine here because the token is high-entropy; passwords need the slow hashes from L18.) As a bonus,crypto.createHash().update()throws aTypeErrorif given an object, so a missing type check fails loudly. - C turns a quiet extraction into an alert: hundreds of failed checks against one account in a minute isn't normal user behaviour.
Also make "valid" and "invalid" responses identical in timing and shape where you can. Every visible difference is a possible oracle.
🧠 Extra takeaways
- A yes/no answer plus attacker-controlled
$regexis enough to read a secret. - Binary search cuts the cost to about log2(alphabet) requests per character.
- Store high-entropy tokens hashed; a leaked hash of a random token is useless to the attacker.
- Rate limits and per-account alerts slow and reveal extraction; type checks prevent it.
Hardening MongoDB itself: server-side JavaScript, least privilege and schema validation
Application code is where NoSQL injection is fixed, but the database decides how bad a missed bug gets. Three settings turn "the attacker controls a query" into "the attacker controls a narrow, boring query".
Switch off server-side JavaScript
If nothing needs it, turn it off. On a self-managed mongod (and mongos), set:
# mongod.conf
security:
javascriptEnabled: false # same as --noscripting
Then $where, $function, $accumulator and mapReduce fail with an error instead of running
attacker JavaScript. Managed platforms expose this differently: MongoDB Atlas has a cluster setting
for server-side JavaScript, so check your platform's current docs. Search your code and any scheduled
jobs (Airflow DAGs, in the example stack) for $where and mapReduce first so you don't break a job.
Give each service only the access it needs
A service that reads statements doesn't need to write them, and it certainly doesn't need
dbAdmin or root. A custom role keeps a successful injection inside one collection:
// run as an admin, against your own dev deployment
use admin
db.createRole({
role: "statementsReader",
privileges: [{
resource: { db: "payments", collection: "statements" },
actions: ["find"] }],
roles: [] })
db.createUser({
user: "statements-api", pwd: passwordPrompt(),
roles: [{ role: "statementsReader", db: "admin" }] })
Now even find(req.query) can't read users, drop a collection or create a user. Use separate
database users for the API, a batch loader (say, an Airflow job) and humans, so one leaked credential (L19) or one
injection bug doesn't carry everyone's rights.
Worked example: stopping second-order injection with a validator
Second-order injection is when a payload is stored first and used in a query later. Picture a profile API that saves the request body as-is:
1. PATCH /v1/profile {"nickname": {"$ne": null}}
──▶ stored: { _id: "u-7781", nickname: {"$ne": null} }
2. later, a batch job: find({ nickname: profile.nickname })
──▶ the stored object is now an OPERATOR: matches all
Since MongoDB 5.0, documents may contain field names that start with $ (with some limits), so the
database won't always refuse step 1 on its own. A schema validator makes it refuse:
db.runCommand({
collMod: "profiles",
validator: { $jsonSchema: {
bsonType: "object",
required: ["nickname"],
properties: {
nickname: { bsonType: "string", maxLength: 40 } } } },
validationLevel: "strict",
validationAction: "error" })
Step 1 now fails with "Document failed validation", whatever the API forgot. The validator is a backstop, not a replacement for the DTO, but it protects every writer at once: the API, a batch loader and anyone with a shell.
🧠 Extra takeaways
security.javascriptEnabled: falseremoves$whereand friends as an attack surface.- One database user per service, with a custom role limited to the collections and actions it needs.
$jsonSchemavalidators enforce field types for every writer, which blocks stored operator objects.- Second-order injection: data you stored yesterday is still untrusted input when you query with it today.
The quiz for this lesson and the Tests section need JavaScript.