CyberNugget

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}), $regex and $where attacks, 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 qs library) turns ?accountId[$ne]=x into {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-parser middleware parses values that start with j: 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              │
 └────────────────────────────────────────────────────┘
  1. 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 String field.
  2. 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).
  3. No string-built queries. No $where, no JSON strings glued together for BasicQuery, 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).
  4. Sanitizers are defence in depth. Middleware such as express-mongo-sanitize strips 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.body or req.query as a filter, and never build $where or query strings from input.
  • Backstops: $eq, Mongoose sanitizeFilter, 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.

Deep dive 1

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 $regex on resetTokenHash can 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 a TypeError if 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 $regex is 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.
Deep dive 2

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: false removes $where and friends as an attack surface.
  • One database user per service, with a custom role limited to the collections and actions it needs.
  • $jsonSchema validators 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.