NoSQL Injection: Complete Code Review Guide
Table of Contents
1. Introduction to NoSQL Injection
NoSQL injection is a vulnerability class that targets applications using NoSQL databases such as MongoDB, Redis, CouchDB, Cassandra, and DynamoDB. Unlike traditional SQL injection, NoSQL injection exploits the query syntax and operators specific to each database, often through JSON-based query objects rather than string concatenation.
A Common Misconception
Many developers believe that NoSQL databases are immune to injection attacks because they don't use SQL. This is dangerously false. NoSQL databases have their own query languages and operators that can be exploited just as effectively as SQL.
The rise of MongoDB as the most popular NoSQL database — combined with Node.js and Express — has made NoSQL injection one of the most prevalent web vulnerabilities in modern JavaScript applications. MongoDB alone powers millions of applications, and its flexible query operators make it particularly susceptible to injection when user input is not properly validated.
In this guide, you'll learn to identify NoSQL injection vulnerabilities during code review, understand the various attack vectors (operator injection, JavaScript injection, and data extraction), and implement robust prevention techniques across different languages and frameworks.
NoSQL Injection Attack Flow
{"$gt": ""}db.users.find(input)Operator injection!Auth bypass / data leak1. Operator Injection
Attacker injects MongoDB query operators like $gt, $ne, or $regex into application inputs to manipulate query logic.
2. JavaScript Injection
Attacker injects server-side JavaScript via $where clauses or mapReduce functions to execute arbitrary code.
3. Data Extraction
Through regex-based extraction, boolean-based blind injection, or timing attacks, sensitive data is exfiltrated.
Why are NoSQL databases vulnerable to injection attacks despite not using SQL?
2. Real-World Scenario
Consider a typical Node.js + Express + MongoDB login endpoint. The developer receives username and password from a JSON request body and queries MongoDB directly:
1// Vulnerable login endpoint
2app.post('/api/login', async (req, res) => {
3 const { username, password } = req.body;
4
5 // VULNERABLE: User input passed directly to MongoDB query
6 const user = await db.collection('users').findOne({
7 username: username,
8 password: password
9 });
10
11 if (user) {
12 // Generate session token
13 const token = generateSessionToken(user._id);
14 res.json({ success: true, token });
15 } else {
16 res.status(401).json({ error: 'Invalid credentials' });
17 }
18});This looks innocent — there's no string concatenation, no eval(), no obvious injection point. But an attacker can send the following JSON payload:
1{
2 "username": "admin",
3 "password": { "$ne": "" }
4}The resulting MongoDB query becomes:
1// What MongoDB actually executes:
2db.collection('users').findOne({
3 username: "admin",
4 password: { $ne: "" } // Matches ANY non-empty password!
5});Authentication Bypass
The $ne (not equal) operator matches any value that is not an empty string. Since all users have non-empty passwords, this query returns the admin user without knowing the actual password — a complete authentication bypass.
The attacker doesn't even need a specific username. They could bypass authentication entirely:
1{
2 "username": { "$gt": "" },
3 "password": { "$gt": "" }
4}This returns the first user in the collection (often an admin) because both conditions match any non-empty string. The attacker gains access without knowing any credentials.
An attacker sends {"username": {"$regex": "^admin"}, "password": {"$ne": null}}. What does this query do?
3. Understanding NoSQL Injection
NoSQL injection differs fundamentally from SQL injection. While SQL injection manipulates string-based query languages, NoSQL injection typically exploits structured query objects — particularly JSON-based queries in MongoDB. Understanding the attack surface requires knowing how NoSQL databases process queries.
NoSQL Injection vs SQL Injection
| Aspect | SQL Injection | NoSQL Injection |
|---|---|---|
| Query Format | String-based SQL statements | JSON objects / key-value pairs |
| Injection Vector | String concatenation | Object/operator injection via JSON |
| Primary Risk | Query manipulation via quotes/comments | Operator injection ($gt, $ne, $where) |
| Code Execution | xp_cmdshell, LOAD_FILE() | $where, mapReduce, $function |
| Data Extraction | UNION SELECT, blind boolean | $regex blind extraction, timing attacks |
| Prevention | Parameterized queries | Input type validation, operator sanitization |
There are three main categories of NoSQL injection:
- Operator Injection — Injecting MongoDB query operators ($gt, $ne, $regex, $or, etc.) into query parameters to manipulate query logic. This is the most common type.
- JavaScript Injection — Injecting server-side JavaScript code via $where clauses, mapReduce, or $function operators. This can lead to arbitrary code execution on the database server.
- Syntax Injection — Manipulating query syntax in databases that use string-based query formats (e.g., Redis command injection, CouchDB view injection).
Why Express + MongoDB Is Especially Vulnerable
Express.js parses JSON request bodies automatically (via express.json() or body-parser). If a client sends {"password": {"$ne": ""}}, Express parses this as a JavaScript object with a nested object — not a string. When this object is passed directly to MongoDB's find(), the $ne operator is interpreted as a query operator, not as literal text.
Dangerous MongoDB Operators for Injection
Comparison Operators
$gt / $gteGreater than — bypasses equality checks$lt / $lteLess than — bypasses equality checks$neNot equal — matches everything except one value$in / $ninIn / not in array — widens match scope$regexRegular expression — enables data extractionLogical & Evaluation Operators
$orLogical OR — adds alternative match conditions$andLogical AND — modifies query logic$whereJS expression — enables code execution$existsField existence — enumerates document structure$typeBSON type check — schema enumerationThe key insight is that NoSQL injection happens at the data structure level rather than the string level. An attacker doesn't need to break out of a string — they need to control the shape of the object being passed to the database query.
What is the fundamental difference between SQL injection and NoSQL injection?
4. MongoDB Operator Injection
Operator injection is the most common form of NoSQL injection in MongoDB applications. It occurs when user-controlled input containing MongoDB query operators is passed directly into database queries. Let's examine the vulnerable patterns systematically.
Authentication Bypass Patterns:
1// Pattern 1: Direct object pass-through (VULNERABLE)
2const user = await User.findOne({
3 email: req.body.email,
4 password: req.body.password // Attacker sends { "$ne": "" }
5});
6
7// Pattern 2: Vulnerable with Mongoose (STILL VULNERABLE)
8const user = await User.findOne({
9 username: req.body.username,
10 password: req.body.password // Mongoose passes operators through!
11});
12
13// Pattern 3: Query building from request (VULNERABLE)
14const query = {};
15if (req.body.username) query.username = req.body.username;
16if (req.body.password) query.password = req.body.password;
17const user = await db.collection('users').findOne(query);Mongoose Does NOT Prevent NoSQL Injection
A common misconception is that using Mongoose (an ODM for MongoDB) prevents NoSQL injection. While Mongoose provides schema validation for writes, it passes query operators through to MongoDB during reads. Mongoose's .findOne() is just as vulnerable to operator injection as the native driver's .findOne().
Data Enumeration via $regex:
1// Attacker can extract data character by character using $regex
2// Step 1: Find if admin password starts with 'a'
3{ "username": "admin", "password": { "$regex": "^a" } }
4
5// Step 2: Find second character
6{ "username": "admin", "password": { "$regex": "^ab" } }
7{ "username": "admin", "password": { "$regex": "^ac" } } // etc.
8
9// Step 3: Continue until full password is extracted
10{ "username": "admin", "password": { "$regex": "^abc123" } }
11
12// This is blind NoSQL injection — extracting data
13// by observing different responses (200 vs 401)Logic Manipulation via $or:
1// Vulnerable search endpoint
2app.get('/api/products', async (req, res) => {
3 const products = await db.collection('products').find({
4 category: req.query.category,
5 public: true
6 }).toArray();
7 res.json(products);
8});
9
10// Attacker sends: ?category[$or][0][public]=false&category[$or][1][public]=true
11// Express query string parser creates:
12// { category: { $or: [{ public: false }, { public: true }] } }
13// This bypasses the public: true filter!Bypassing Access Controls:
1// Vulnerable endpoint — find documents by user ID
2app.get('/api/documents/:userId', async (req, res) => {
3 const docs = await db.collection('documents').find({
4 owner: req.params.userId, // Intended: specific user
5 visibility: req.query.visibility // VULNERABLE to operator injection
6 }).toArray();
7 res.json(docs);
8});
9
10// Attacker sends: /api/documents/myId?visibility[$ne]=null
11// Returns ALL documents regardless of visibility setting
12
13// Even worse with $or injection:
14// ?visibility[$or][0][owner]=victimId&visibility[$or][1][owner]=myId
15// Returns victim's documents too!An application uses: db.users.find({ role: req.query.role }). An attacker sends ?role[$ne]=admin. What happens?
5. Finding Vulnerable Sinks
During code review, identifying NoSQL injection requires finding sinks (database query functions) that receive tainted input (user-controlled data) without proper sanitization. Here are the key sinks to look for across different languages and frameworks.
Node.js / MongoDB Native Driver Sinks:
1// HIGH RISK: Direct query methods
2collection.find(userInput) // Find documents
3collection.findOne(userInput) // Find single document
4collection.findOneAndUpdate(userInput, update) // Find and update
5collection.findOneAndDelete(userInput) // Find and delete
6collection.updateOne(userInput, update) // Update matching doc
7collection.updateMany(userInput, update) // Update all matching
8collection.deleteOne(userInput) // Delete matching doc
9collection.deleteMany(userInput) // Delete all matching
10collection.countDocuments(userInput) // Count matching docs
11collection.distinct(field, userInput) // Distinct values with filter
12
13// CRITICAL: Aggregation pipeline
14collection.aggregate([
15 { $match: userInput }, // Operator injection in $match
16 { $group: userInput } // Injection in $group
17])
18
19// CRITICAL: JavaScript execution
20collection.find({ $where: userInput }) // Server-side JS execution
21collection.mapReduce(mapFn, reduceFn, { query: userInput })Mongoose (Node.js ODM) Sinks:
1// All Mongoose query methods are vulnerable to operator injection
2Model.find(userInput)
3Model.findOne(userInput)
4Model.findById(userInput) // Less risky — expects ObjectId
5Model.findOneAndUpdate(userInput, update)
6Model.findOneAndDelete(userInput)
7Model.where(field).equals(userInput) // Chained queries too
8Model.countDocuments(userInput)
9
10// Mongoose does NOT sanitize query operators by default!
11// Even with schema validation, query operators pass throughPython / PyMongo Sinks:
1# PyMongo - MongoDB native driver for Python
2collection.find(user_input)
3collection.find_one(user_input)
4collection.update_one(user_input, update)
5collection.update_many(user_input, update)
6collection.delete_one(user_input)
7collection.delete_many(user_input)
8collection.count_documents(user_input)
9collection.aggregate([{"$match": user_input}])
10
11# Flask example - vulnerable pattern
12@app.route('/api/login', methods=['POST'])
13def login():
14 data = request.get_json() # Parses JSON body
15 user = db.users.find_one({
16 'username': data['username'], # VULNERABLE
17 'password': data['password'] # VULNERABLE
18 })
19
20# Django + djongo / mongoengine
21MyModel.objects.raw(user_input) # Raw query
22MyModel.objects.filter(**user_input) # Dict unpacking!Java / Spring Data MongoDB Sinks:
1// Spring Data MongoDB
2Query query = new Query(Criteria.where("username").is(userInput));
3mongoTemplate.find(query, User.class);
4
5// Vulnerable: Building queries from raw input
6BasicDBObject queryObj = BasicDBObject.parse(userInputJson);
7collection.find(queryObj); // CRITICAL: Parses arbitrary operators
8
9// Vulnerable: Using @Query with SpEL
10@Query("{ 'username': ?0, 'password': ?1 }")
11User findByCredentials(String username, String password);
12// If input contains {"$ne":""}, the operator is interpreted!
13
14// MongoTemplate with Criteria
15Criteria criteria = Criteria.where(fieldName).is(value);
16// If 'value' is a Map containing operators, they're interpretedNoSQL Injection Sink Severity Matrix
| Sink Type | Risk Level | Impact | Common In |
|---|---|---|---|
| find() / findOne() with user object | Critical | Auth bypass, data leak | Node.js, Python |
| $where / mapReduce with user string | Critical | Remote code execution | All MongoDB drivers |
| aggregate() with user-controlled $match | High | Data leak, filter bypass | Node.js, Python, Java |
| updateOne() with user-controlled filter | High | Unauthorized modification | All frameworks |
| deleteMany() with user-controlled filter | Critical | Mass data deletion | All frameworks |
| Query string parsed objects (?key[$ne]=val) | High | Operator injection via URL | Express, Flask |
Which of these Mongoose patterns is vulnerable to NoSQL injection?
6. Server-Side JavaScript Injection
Beyond operator injection, MongoDB supports server-side JavaScript execution through several mechanisms. When user input reaches these execution contexts, it can lead to Remote Code Execution (RCE) on the database server — a much more severe impact than data leakage.
Critical: Server-Side JavaScript = RCE
MongoDB's $where, mapReduce, $accumulator, and $function operators execute JavaScript on the server. If user input reaches these operators, an attacker can execute arbitrary code, read files, make network requests, and potentially compromise the entire database server.
$where Injection:
1// CRITICAL VULNERABILITY: $where with user input
2app.get('/api/search', async (req, res) => {
3 const searchTerm = req.query.q;
4
5 // VULNERABLE: User input in $where JavaScript expression
6 const results = await db.collection('products').find({
7 $where: `this.name.includes('${searchTerm}')`
8 }).toArray();
9
10 res.json(results);
11});
12
13// Attacker sends: ?q=') || true || ('
14// Resulting $where: this.name.includes('') || true || ('')
15// Returns ALL documents in the collection
16
17// Worse attack: ?q='); sleep(5000); ('
18// Causes 5-second delay — confirms JavaScript execution
19
20// RCE payload (MongoDB < 4.4):
21// ?q='); var fs = require('fs'); return fs.readFileSync('/etc/passwd').toString(); ('mapReduce Injection:
1// VULNERABLE: mapReduce with user-influenced functions
2app.post('/api/analytics', async (req, res) => {
3 const { groupBy } = req.body;
4
5 // VULNERABLE: User input in JavaScript function
6 const result = await db.collection('orders').mapReduce(
7 new Function(`emit(this.${groupBy}, this.amount)`), // Map function
8 function(key, values) { return Array.sum(values); }, // Reduce function
9 { out: { inline: 1 } }
10 );
11
12 res.json(result);
13});
14
15// Attacker sends: { "groupBy": "status); db.users.find().forEach(function(u){emit(u.email, u.password" }
16// This breaks out of the field reference and accesses other collections$function and $accumulator (MongoDB 4.4+):
1// MongoDB 4.4+ introduced $function and $accumulator operators
2// These execute JavaScript in the aggregation pipeline
3
4// VULNERABLE: User input in $function body
5app.post('/api/transform', async (req, res) => {
6 const { transform } = req.body;
7
8 const results = await db.collection('data').aggregate([
9 {
10 $addFields: {
11 computed: {
12 $function: {
13 body: transform, // CRITICAL: User-controlled JS code
14 args: ["$value"],
15 lang: "js"
16 }
17 }
18 }
19 }
20 ]).toArray();
21
22 res.json(results);
23});
24
25// Attacker sends: { "transform": "function(v) { return db.adminCommand({listDatabases:1}) }" }Safe Alternatives to $where:
1// INSTEAD of $where, use MongoDB query operators:
2
3// BAD: $where with string matching
4db.products.find({ $where: "this.name.includes('laptop')" })
5
6// GOOD: $regex operator (still validate input!)
7db.products.find({ name: { $regex: /laptop/i } })
8
9// BAD: $where with comparison
10db.products.find({ $where: "this.price > 100 && this.price < 500" })
11
12// GOOD: Native operators
13db.products.find({ price: { $gt: 100, $lt: 500 } })
14
15// BAD: $where with complex logic
16db.orders.find({ $where: "this.items.length > 5" })
17
18// GOOD: $expr with $size
19db.orders.find({ $expr: { $gt: [{ $size: "$items" }, 5] } })A developer uses: db.find({ $where: `this.status === '${userInput}'` }). An attacker sends: ' || '' === '. What happens?
7. Prevention Techniques
Preventing NoSQL injection requires a defense-in-depth approach that combines input validation, query sanitization, and architectural controls. Unlike SQL injection, there is no equivalent of "parameterized queries" for MongoDB — so prevention relies on disciplined input handling.
1. Input Type Validation (Most Important):
1// The #1 defense: Ensure inputs are the expected type
2// NEVER pass raw request body fields to MongoDB queries
3
4// GOOD: Explicit string casting
5app.post('/api/login', async (req, res) => {
6 const username = String(req.body.username); // Forces string type
7 const password = String(req.body.password); // { "$ne": "" } becomes "[object Object]"
8
9 const user = await db.collection('users').findOne({
10 username: username,
11 password: password // Now safe — always a string
12 });
13});
14
15// GOOD: Type checking before query
16app.post('/api/login', async (req, res) => {
17 const { username, password } = req.body;
18
19 // Reject non-string inputs
20 if (typeof username !== 'string' || typeof password !== 'string') {
21 return res.status(400).json({ error: 'Invalid input type' });
22 }
23
24 const user = await User.findOne({ username, password });
25});2. Schema Validation with Joi / Zod:
1// Use schema validation to enforce types BEFORE reaching the database
2
3// With Zod
4import { z } from 'zod';
5
6const loginSchema = z.object({
7 username: z.string().min(1).max(50),
8 password: z.string().min(8).max(128),
9});
10
11app.post('/api/login', async (req, res) => {
12 // Zod rejects objects like { "$ne": "" } — they're not strings
13 const result = loginSchema.safeParse(req.body);
14 if (!result.success) {
15 return res.status(400).json({ error: 'Invalid input' });
16 }
17
18 const { username, password } = result.data;
19 const user = await User.findOne({ username, password });
20});
21
22// With Joi
23import Joi from 'joi';
24
25const loginSchema = Joi.object({
26 username: Joi.string().required().max(50),
27 password: Joi.string().required().max(128),
28});3. Sanitize MongoDB Operators (mongo-sanitize):
1// npm install mongo-sanitize
2import sanitize from 'mongo-sanitize';
3
4app.post('/api/login', async (req, res) => {
5 // Strips any keys starting with $ from the input
6 const cleanBody = sanitize(req.body);
7
8 const user = await User.findOne({
9 username: cleanBody.username,
10 password: cleanBody.password
11 });
12});
13
14// Before: { username: "admin", password: { "$ne": "" } }
15// After: { username: "admin", password: {} }
16
17// You can also sanitize individual fields:
18const username = sanitize(req.body.username);
19const password = sanitize(req.body.password);4. Express middleware for global sanitization:
1// Global middleware to strip MongoDB operators from all requests
2import sanitize from 'mongo-sanitize';
3
4// Apply to all routes
5app.use((req, res, next) => {
6 if (req.body) req.body = sanitize(req.body);
7 if (req.query) req.query = sanitize(req.query);
8 if (req.params) req.params = sanitize(req.params);
9 next();
10});
11
12// Or use express-mongo-sanitize package
13import mongoSanitize from 'express-mongo-sanitize';
14
15app.use(mongoSanitize()); // Strips $ and . from req.body, req.query, req.params
16
17// With options:
18app.use(mongoSanitize({
19 replaceWith: '_', // Replace $ with _ instead of removing
20 allowDots: false, // Also sanitize dots (for nested keys)
21 dryRun: false, // Set to true for logging without blocking
22}));5. Disable Server-Side JavaScript:
1# In mongod.conf — disable server-side JavaScript execution
2security:
3 javascriptEnabled: false
4
5# Or via command line:
6# mongod --noscripting
7
8# This disables:
9# - $where operator
10# - mapReduce JavaScript functions
11# - $accumulator operator
12# - $function operator
13# - db.eval() (removed in MongoDB 4.2)6. Python Prevention Patterns:
1# Python - Pydantic validation
2from pydantic import BaseModel, validator
3
4class LoginRequest(BaseModel):
5 username: str
6 password: str
7
8 @validator('username', 'password')
9 def must_be_string(cls, v):
10 if not isinstance(v, str):
11 raise ValueError('Must be a string')
12 return v
13
14@app.route('/api/login', methods=['POST'])
15def login():
16 try:
17 data = LoginRequest(**request.get_json())
18 except ValueError:
19 return jsonify({'error': 'Invalid input'}), 400
20
21 user = db.users.find_one({
22 'username': data.username,
23 'password': data.password # Safe — validated as string
24 })
25
26# Manual sanitization function
27def sanitize_input(data):
28 """Remove keys starting with $ from nested dicts"""
29 if isinstance(data, dict):
30 return {
31 k: sanitize_input(v)
32 for k, v in data.items()
33 if not k.startswith('$')
34 }
35 if isinstance(data, list):
36 return [sanitize_input(item) for item in data]
37 return dataPrevention Hierarchy
1) Validate input types with schema validation (Zod/Joi/Pydantic) — reject non-strings. 2) Use mongo-sanitize or express-mongo-sanitize as a safety net. 3) Disable server-side JavaScript (--noscripting) in MongoDB. 4) Use allowlists for any dynamic field names in queries. 5) Apply least-privilege database roles — don't use admin connections.
Which prevention technique is the MOST effective single defense against NoSQL injection?