CWE-943: Improper Neutralization of Special Elements in Data Query Logic - JavaScript
Overview
NoSQL Injection in JavaScript/Node.js applications occurs when untrusted input is used to construct NoSQL database queries (MongoDB, Redis, CouchDB, etc.) without proper validation. Untrusted input can originate from HTTP requests, external APIs, databases, files, message queues, or any source outside the application's control. Attackers use it to bypass authentication, read documents the endpoint was never meant to return, overwrite entries another part of the application owns, or run JavaScript on the MongoDB server.
Primary Defence: Check that every value going into a filter is a primitive - typeof x === 'string' or 'number' - on the code path that builds the query, and allowlist any request field that names a field rather than supplying a value. Build the filter object in application code so the keys and operators are literals. A Mongoose schema will not do it for you: schema types cast and validate documents, not query filters, so Model.findOne({ username: { $ne: 'admin' } }) reaches MongoDB intact on a strict: true schema. The Mongoose control that does apply to filters is sanitizeFilter, which is off by default. Run the application's database account with least privilege as well - a query the attacker reshapes can only reach what the credential permits.
Common Node.js NoSQL Vulnerabilities:
- MongoDB query operator injection (
$ne,$gt,$where,$regex) - MongoDB aggregation pipeline manipulation
- Redis key-namespace injection, and Lua script injection via
EVAL - CouchDB Mango query injection
- DynamoDB expression attribute injection
Popular Node.js NoSQL Libraries:
- mongodb: Official MongoDB driver
- mongoose: MongoDB ODM
- ioredis / redis: Redis clients
- nano: CouchDB client
- aws-sdk: DynamoDB client
Common Vulnerable Patterns
MongoDB Operator Injection
// VULNERABLE - Direct untrusted input in MongoDB query
const { MongoClient } = require('mongodb');
const client = new MongoClient('mongodb://localhost:27017');
const db = client.db('app_database');
async function authenticateUser(username, password) {
// VULNERABLE - Untrusted input directly in query
const user = await db.collection('users').findOne({
username: username,
password: password
});
return user !== null;
}
// Attack: username = {$ne: null}, password = {$ne: null}
// Query becomes: {username: {$ne: null}, password: {$ne: null}}
// Returns first user (authentication bypass!)
Why this is vulnerable: authenticateUser assumes both arguments are strings and never checks. JavaScript has no signature to enforce that, so whatever the caller passed goes into the filter object as-is: pass {$ne: null} for password and the driver serialises it as an operator, matching the named user whatever their stored password is.
Nothing is concatenated and no character needs escaping - the injection is a change of type, which is why input filtering aimed at quotes or semicolons does not touch it. The property to enforce is that a field the query treats as a scalar arrives as a scalar, and the check has to sit on the path that builds the filter rather than at the edge of the request.
Express API with JSON Injection
// VULNERABLE - Accepting arbitrary JSON in queries
const express = require('express');
const { MongoClient } = require('mongodb');
const app = express();
app.use(express.json());
const client = new MongoClient('mongodb://localhost:27017');
const db = client.db('shop');
app.post('/api/products', async (req, res) => {
// VULNERABLE - Arbitrary query object from untrusted source
const query = req.body.query || {};
// No validation on query structure
const products = await db.collection('products')
.find(query)
.toArray();
res.json(products);
});
// Attack POST body: {"query": {"price": {"$gt": 0}, "admin_only": {"$ne": true}}}
// Bypasses access controls, retrieves admin products
Why this is vulnerable: req.body.query becomes the filter unchanged, so the caller chooses the fields, the operators and the values. Whatever the endpoint was meant to search, every document in the collection is reachable: filter on an internal flag, use $ne to invert a restriction the UI applies, or use $regex to read a field out one character at a time.
The || {} fallback makes the untouched case worse rather than safer - a request with no query key returns the first page of the whole collection instead of an error. An endpoint that takes a filter document from a client cannot be repaired by validating it; it has to take named parameters instead and build the filter itself.
MongoDB $where Operator Injection
// VULNERABLE - JavaScript code injection via $where
const { MongoClient } = require('mongodb');
const client = new MongoClient('mongodb://localhost:27017');
const db = client.db('app');
async function findUsersByAge(age) {
// VULNERABLE - String concatenation in $where
const query = {
$where: `this.age > ${age}`
};
const users = await db.collection('users')
.find(query)
.toArray();
return users;
}
// Attack: age = "0 || true"
// Expression becomes: this.age > 0 || true -> matches every document
// Attack: age = "0 || (function(){ while(true){} })()"
// Runs an unbounded loop inside the server's JavaScript engine
Why this is vulnerable: $where hands a JavaScript expression to the MongoDB server, which evaluates it once per candidate document, and the template literal drops the caller's text straight into it. An || makes the predicate unconditionally true; a function expression that never returns occupies a server thread; this.<field> reaches any field on the document, including ones this endpoint never returns.
$where was deprecated in MongoDB 8.0 - the server logs a warning - but it is not removed, and server-side scripting is enabled by default, so a payload that reaches it runs. The payloads above are expressions rather than statement lists (0; return true; //) because a statement list depends on how the server wraps the string, while an expression holds under any wrapping. $expr with standard aggregation operators covers most of what $where is used for and executes nothing.
Mongoose with Unsafe Queries
// VULNERABLE - Mongoose with query injection
const express = require('express');
const mongoose = require('mongoose');
const User = mongoose.model('User', {
username: String,
email: String,
role: String
});
const app = express();
app.use(express.json());
app.get('/api/user', async (req, res) => {
const { username } = req.query;
// VULNERABLE - Untrusted input directly in query
const user = await User.findOne({ username }).exec();
res.json(user);
});
// Attack (Express 4): ?username[$ne]=admin
// req.query.username is the object {$ne: 'admin'}, and the filter becomes
// {username: {$ne: 'admin'}} - returns the first user who is not admin
Why this is vulnerable: req.query.username is destructured and passed to findOne with no check that it is a string, so whatever the query parser produced reaches MongoDB as query structure.
Which parser you have decides whether the query string can produce an object at all, and the default moved. Measured: on Express 4.22.2 ?username[$ne]=admin yields { username: { $ne: 'admin' } }; on Express 5.2.1 the same request yields { 'username[$ne]': 'admin' }, a single key with a literal name, because Express 5 changed the default query parser from extended to simple. So this handler is exploitable through the URL on Express 4, and on Express 5 only if the application has set app.set('query parser', 'extended') - which is the documented way to restore nested query parameters when they stop arriving after the upgrade. Check the setting before deciding either way.
Do not read that as "Express 5 fixed it". express.json() parses a body into real nested objects on every version, so the same handler written against req.body is exploitable regardless, and the missing typeof check is the defect in both cases.
A schema does not close this either. On Mongoose 9.9.3 with strict: true and username: String, { username: { $ne: 'admin' } } reaches MongoDB unchanged - casting applies the schema's types to the value inside the operator, not to whether an operator is allowed.
Unvalidated Key Path in Redis
// VULNERABLE - Redis key chosen by the caller
const express = require('express');
const redis = require('redis');
const app = express();
const client = redis.createClient();
app.get('/cache/:key', async (req, res) => {
const { key } = req.params;
// VULNERABLE - Untrusted input in Redis key
const value = await client.get(key);
res.send(value || 'Not found');
});
app.post('/cache', express.json(), async (req, res) => {
const { key, value } = req.body;
// VULNERABLE - the caller names the key that gets written
await client.set(key, value);
res.send('OK');
});
// Attack: key = "session:9f2a"
// Reads or overwrites a key belonging to another part of the application
Why this is vulnerable: Both handlers let the caller name the key outright. GET /cache/:key returns whatever is stored under it, so any value the application caches - session records, password-reset codes, rate-limit counters - is one request away, and the POST handler overwrites any of them.
The payload usually shown for this, key = "test\r\nFLUSHDB\r\n", does not work, and repeating it hides the weakness that does. RESP length-prefixes every argument. Captured from node-redis 6.2.1, that exact set puts this on the socket:
$15 tells the server to read 15 bytes and treat them as one key, so the CRLF is data and FLUSHDB is stored rather than executed. Nothing strips it either, so removing newlines from a value defends nothing and corrupts data that legitimately contains them. Redis command injection through node-redis needs a different sink: user input concatenated into the source of a Lua script passed to EVAL, or a pattern handed to KEYS, which scans the whole keyspace.
MongoDB Aggregation Injection
// VULNERABLE - Aggregation pipeline with untrusted input
const { MongoClient } = require('mongodb');
const client = new MongoClient('mongodb://localhost:27017');
const db = client.db('analytics');
async function getUserStats(userId, sortField) {
// VULNERABLE - Untrusted input in aggregation pipeline
const pipeline = [
{ $match: { user_id: userId } },
{ $sort: { [sortField]: -1 } },
{ $limit: 10 }
];
const results = await db.collection('events')
.aggregate(pipeline)
.toArray();
return results;
}
// Attack: sortField = "password_hash"
// Orders results by a field the caller was never shown, leaking its ordering
// Attack: sortField = "last_seen_ip" (no index)
// Forces a blocking sort over the whole match, spilling to disk
Why this is vulnerable: The caller chooses which field the pipeline sorts on. That is not operator injection, and calling it that sends readers looking for the wrong thing: a $sort value is 1, -1 or {$meta: ...}, so a sort key never becomes something the server executes, and sortField = "$where" produces { $sort: { $where: -1 } }, which is a sort specification the server rejects rather than a code path.
What the attacker does get is real. Sorting on a field the endpoint does not return still leaks that field's ordering, which is enough to binary-search a hidden value across requests, and naming an unindexed field turns a cheap indexed scan into a blocking sort over the whole match. Anything that names a field - sort target, filter key, projection - needs an allowlist, because value validation does not cover it.
MongoDB Regex Injection
// VULNERABLE - Regex injection in queries
const { MongoClient } = require('mongodb');
const client = new MongoClient('mongodb://localhost:27017');
const db = client.db('app');
async function searchUsers(searchTerm) {
// VULNERABLE - Untrusted input in regex without escaping
const query = {
username: { $regex: searchTerm, $options: 'i' }
};
const users = await db.collection('users')
.find(query)
.toArray();
return users;
}
// Attack: searchTerm = ".*"
// Returns ALL users (data exfiltration)
// Attack: searchTerm = "^admin"
// Confirms which usernames start with a given prefix, one request at a time
// Attack: searchTerm = "(a+)+$"
// Catastrophic backtracking against a long non-matching username
Why this is vulnerable: The search term is used as a pattern, so every regex metacharacter the caller types is honoured. .* turns a search into a full dump; an anchored prefix turns it into an oracle that reveals stored values character by character across repeated requests; a pattern chosen for backtracking cost makes the server do exponential work per document scanned.
The anchor in the last payload is the part worth noticing, because (a+)+ on its own is repeated everywhere as a ReDoS example and is not one. Measured in Node 24: /(a+)+/ against 28 as and a b matches in 0.02 ms, because it succeeds immediately on a prefix. /(a+)+$/ against the same string takes 30.6 seconds - the $ forces the engine to fail and retry every way of splitting the run. A payload without something that forces the mismatch is not a test of anything.
Next.js API Route Injection
// VULNERABLE - Next.js API route with query injection
// pages/api/users.js
import { MongoClient } from 'mongodb';
const client = new MongoClient(process.env.MONGODB_URI);
export default async function handler(req, res) {
const { method, query } = req;
if (method !== 'GET') {
return res.status(405).end();
}
const db = client.db('app');
// VULNERABLE - Query parameters directly in MongoDB query
const users = await db.collection('users')
.find(query)
.toArray();
res.json(users);
}
// Attack: /api/users?email=victim@example.com
// Returns that user's whole document, password hash included
// Attack: /api/users?password_reset_token=<guess>
// Turns the endpoint into an oracle for any field in the collection
Why this is vulnerable: req.query becomes the filter, so the caller decides which field is compared and to what, and the handler returns whole documents with no projection. Every field in the collection is both queryable and readable: look a user up by email and read their hash, or probe a reset token a field at a time.
The payload usually attached to this example does not fire. Measured on Next 16.3.2, ?role[$ne]=user parses to { 'role[$ne]': 'user' } - one key with a literal name - because Next builds req.query from URLSearchParams, which has no nested form. Repeated keys do give you an array (?tags=a&tags=b becomes ['a','b']), but an operator object has to arrive some other way: a JSON body, or a query parser someone swapped in. The field-choice weakness above needs neither, which is why it is the one to fix.
Secure Patterns
MongoDB with Type Validation
// SECURE - Strict type validation for MongoDB queries
const bcrypt = require('bcrypt');
const { MongoClient } = require('mongodb');
const client = new MongoClient('mongodb://localhost:27017');
const db = client.db('app_database');
function validateString(value, maxLength = 100) {
if (typeof value !== 'string') {
throw new Error('Expected string value');
}
if (value.length > maxLength) {
throw new Error(`Value exceeds max length ${maxLength}`);
}
return value;
}
// A real bcrypt hash (cost 12) of a passphrase no account uses. Comparing
// against it costs what comparing against a stored hash costs.
const DUMMY_HASH = '$2b$12$rkQQN4aSNYsR2VwBW5cwj..dFvrX.eA/fcupZlyvg.oGY1Op9q0wu';
async function authenticateUser(username, password) {
// SECURE - Validate input types
const cleanUsername = validateString(username, 50);
const cleanPassword = validateString(password, 100);
// SECURE - the password is not part of the filter; look up by name only
const user = await db.collection('users').findOne({ username: cleanUsername });
// SECURE - hash on both paths. Returning early when the user does not exist
// would make an unknown username far faster than a wrong password.
const stored = user ? user.passwordHash : DUMMY_HASH;
const ok = await bcrypt.compare(cleanPassword, stored);
return user !== null && ok;
}
Why this works: validateString() runs on the path that builds the filter, and typeof value !== 'string' is the whole control: { $ne: null } is an object, so it never reaches findOne. Placing the check here rather than in a middleware or a request schema matters, because a filter assembled from a separately parsed body is a different object from the one the schema validated.
The password is not in the query. Filtering on it would put the one value worth guessing into the part of the request an attacker reshapes; comparing the hash in the application keeps the query to a lookup, and a database dump then yields hashes rather than passwords.
Hashing on both paths is the part most often dropped. if (!user) return false skips bcrypt entirely, and the gap is not subtle: measured with bcrypt 6.0.0 at cost 12, comparing against DUMMY_HASH takes 213.5 ms against 213.7 ms for a real hash, where an early return answers in microseconds. That difference tells an attacker which usernames exist - see CWE-208 and CWE-287.
Dependencies: npm install bcrypt (6.0.0 at the time of writing).
Express with Query Allowlist
// SECURE - Field allowlist and validation
const express = require('express');
const { MongoClient } = require('mongodb');
const app = express();
app.use(express.json());
const client = new MongoClient('mongodb://localhost:27017');
const db = client.db('shop');
function parseText(raw) {
if (typeof raw !== 'string') throw new Error('expected a string');
if (raw.length > 100) throw new Error('value too long');
return raw;
}
function parsePrice(raw) {
if (typeof raw !== 'string' && typeof raw !== 'number') {
throw new Error('expected a number');
}
// Number('') is 0 and Number('10abc') is NaN, so reject both explicitly
const price = raw === '' ? NaN : Number(raw);
if (!Number.isFinite(price) || price < 0 || price > 1e6) {
throw new Error('price out of range');
}
return price;
}
// SECURE - each allowed field names the parser that produces its value
const ALLOWED_FIELDS = {
name: parseText,
category: parseText,
price_min: parsePrice,
price_max: parsePrice
};
function buildSafeQuery(params) {
const query = {};
const price = {};
for (const [field, raw] of Object.entries(params)) {
// SECURE - Only allow allowlisted fields
const parse = ALLOWED_FIELDS[field];
if (!parse) {
continue;
}
// SECURE - convert, rather than check: req.query values are all strings
const value = parse(raw);
// SECURE - the operator is chosen by the field name, not by the caller
if (field === 'price_min') {
price.$gte = value;
} else if (field === 'price_max') {
price.$lte = value;
} else {
query[field] = value;
}
}
if (Object.keys(price).length > 0) {
query.price = price;
}
return query;
}
app.get('/api/products', async (req, res) => {
let safeQuery;
try {
// SECURE - Build validated query
safeQuery = buildSafeQuery(req.query);
} catch (error) {
return res.status(400).json({ error: error.message });
}
const products = await db.collection('products')
.find(safeQuery)
.limit(100)
.toArray();
res.json(products);
});
app.listen(3000);
Why this works: Every key in the filter is a literal written in this function. ALLOWED_FIELDS decides which request fields are looked at, the if chain decides which operator each becomes, and the caller supplies only the value on the right-hand side. A request carrying admin_only, $where or __proto__ finds no entry and is dropped.
The part worth copying is that values are parsed, not type-checked. req.query values are strings on every Express version, so the more obvious typeof value !== 'number' matches nothing and silently drops both price bounds - measured, buildSafeQuery({category: 'tools', price_min: '20'}) written that way returns {category: 'tools'}. The endpoint keeps working, returns products, passes every injection test, and has quietly stopped filtering by price. parsePrice either produces a number or throws, so a field that survives is the type the query needs and a field that does not gets a 400 rather than being ignored.
Number('') is 0 and Number('10abc') is NaN, which is why both are rejected explicitly rather than left to a truthiness check. .limit(100) bounds the result set.
Mongoose with Schema Validation
// SECURE - Mongoose with strict schema validation
const express = require('express');
const mongoose = require('mongoose');
const userSchema = new mongoose.Schema({
username: {
type: String,
required: true,
maxlength: 50,
match: /^[a-zA-Z0-9_]+$/
},
email: {
type: String,
required: true,
match: /^[\w.-]+@[\w.-]+\.\w+$/
},
role: {
type: String,
enum: ['user', 'admin']
}
}, { strict: true });
const User = mongoose.model('User', userSchema);
// SECURE - applies to query filters, unlike the schema above. Off by default;
// it wraps any object value in $eq, so {$ne: 'admin'} becomes a literal to
// match rather than an operator to run.
mongoose.set('sanitizeFilter', true);
const app = express();
app.use(express.json());
function validateUsername(username) {
if (typeof username !== 'string') {
throw new Error('Username must be a string');
}
if (!/^[a-zA-Z0-9_]{3,50}$/.test(username)) {
throw new Error('Invalid username format');
}
return username;
}
app.get('/api/user', async (req, res) => {
try {
const username = validateUsername(req.query.username);
// SECURE - Use validated string, schema enforces types
const user = await User.findOne({ username }).exec();
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
} catch (error) {
// The catch covers findOne as well as the validator, and a driver error names
// the host it could not reach: `connect ECONNREFUSED 10.1.2.3:27017`.
console.warn('user lookup rejected', { reason: error.message });
res.status(400).json({ error: 'Invalid request' });
}
});
app.listen(3000);
Why this works: validateUsername() is the control. It runs typeof username !== 'string' and then a fullmatch-style pattern on the value that goes into the filter, so an object carrying $ne is rejected before findOne is called.
One thing the handler around it has to get right: the catch also covers findOne, so a driver error would be returned as a 400 with the driver's own text if it answered with error.message. It logs that and answers Invalid request instead.
mongoose.set('sanitizeFilter', true) is the second layer and the one that is specific to this ODM. Measured on Mongoose 9.9.3: with it on, a filter of { username: { $ne: 'admin' } } is rewritten to { username: { $eq: { $ne: 'admin' } } }, so MongoDB looks for a stored value that is literally that object and finds nothing. It is off by default, and it has to be set globally or passed per query - a schema does not turn it on.
Be clear about what the schema does not do here, because it is the most common wrong answer to this CWE. type: String, match, enum and strict: true govern documents: they cast and validate on construction and save(). They are not applied to query filters. On the same Mongoose version, a strict: true schema passes { username: { $ne: 'admin' } } and even { $where: '...' } through to the server untouched. Schema casting will coerce the value inside an operator to the declared type; it will not decide that the operator should not be there.
Redis with an Application-Composed Key
// SECURE - the application decides the key, the caller supplies one segment
const express = require('express');
const redis = require('redis');
const app = express();
app.use(express.json());
const client = redis.createClient();
function validateRedisKey(key) {
if (typeof key !== 'string') {
throw new Error('Key must be a string');
}
// SECURE - Only allow alphanumeric, dash, underscore
if (!/^[a-zA-Z0-9_-]{1,100}$/.test(key)) {
throw new Error('Invalid key format');
}
return key;
}
function validateRedisValue(value) {
if (typeof value !== 'string') {
throw new Error('Value must be a string');
}
// SECURE - bound the size. The contents need no filtering; see below.
if (value.length > 10000) {
throw new Error('Value too large');
}
return value;
}
app.get('/cache/:key', async (req, res) => {
try {
const cleanKey = validateRedisKey(req.params.key);
const value = await client.get(cleanKey);
res.send(value || 'Not found');
} catch (error) {
console.warn('cache read rejected', { reason: error.message });
res.status(400).send('Invalid request');
}
});
app.post('/cache', async (req, res) => {
try {
const cleanKey = validateRedisKey(req.body.key);
const cleanValue = validateRedisValue(req.body.value);
// SECURE - Use setex with expiration
await client.setEx(cleanKey, 3600, cleanValue);
res.send('OK');
} catch (error) {
console.warn('cache write rejected', { reason: error.message });
res.status(400).send('Invalid request');
}
});
app.listen(3000);
Why this works: /^[a-zA-Z0-9_-]{1,100}$/ excludes :, and that exclusion is the control rather than a general tidiness measure. : is what separates namespaces in a Redis keyspace, so a caller who cannot type one cannot climb out of the namespace this handler owns into session: or reset:. A key pattern that permitted : would look equally strict and stop nothing.
Both handlers answer with a fixed string rather than error.message. The catch covers the client.get and client.setEx calls as well as the validators, and a node-redis failure carries the address it could not reach - connect ECONNREFUSED 10.1.2.3:6379 - or The client is closed. Neither tells the caller anything they should have.
The value is bounded but not filtered, deliberately. Stripping \r and \n from a cached value defends against a protocol attack that does not exist - RESP length-prefixes every argument, so a newline in a value is data - and it silently corrupts anything with a legitimate line break, such as a cached document or a PEM block. Bound the size, because that is a real resource limit; leave the bytes alone.
setEx attaches a TTL, so a poisoned or stale entry ages out instead of persisting until someone notices. For Lua, pass values through KEYS/ARGV rather than concatenating them into the script text - the script source is the one place in a Redis client where user input really is parsed as code.
Safe MongoDB Aggregation
// SECURE - MongoDB aggregation with field allowlist
const { MongoClient } = require('mongodb');
const client = new MongoClient('mongodb://localhost:27017');
const db = client.db('analytics');
// SECURE - Define allowed sort fields
const ALLOWED_SORT_FIELDS = ['timestamp', 'event_type', 'user_id'];
function validateUserId(userId) {
if (typeof userId !== 'string') {
throw new Error('User ID must be a string');
}
if (!/^[a-zA-Z0-9_-]{1,50}$/.test(userId)) {
throw new Error('Invalid user ID format');
}
return userId;
}
async function getUserStats(userId, sortField) {
// SECURE - Validate user ID
const cleanUserId = validateUserId(userId);
// SECURE - Validate sort field against allowlist
if (!ALLOWED_SORT_FIELDS.includes(sortField)) {
throw new Error(`Invalid sort field. Allowed: ${ALLOWED_SORT_FIELDS.join(', ')}`);
}
// SECURE - Build pipeline with validated values
const pipeline = [
{ $match: { user_id: cleanUserId } },
{ $sort: { [sortField]: -1 } },
{ $limit: 100 }
];
const results = await db.collection('events')
.aggregate(pipeline)
.toArray();
return results;
}
Why this works: The three stages, their order and both operators are written here; the request contributes one match value and the name of one sort field. Because the pipeline is an array built in code rather than parsed from a body, there is no position in which a caller could add a stage such as $lookup or $function.
ALLOWED_SORT_FIELDS is doing something narrower than stopping code execution, and it is worth naming precisely. A $sort value is 1, -1 or {$meta: ...}, so a sort key never becomes something the server runs. What the allowlist prevents is sorting on a field the endpoint does not return - which still leaks that field's ordering, one request at a time - and sorting on an unindexed field, which turns an indexed scan into a blocking sort over the whole match. Both are reasons to allowlist anything that names a field, alongside validating anything that supplies a value.
validateUserId pins the alphabet of the match value, and $limit: 100 caps the documents the stage emits, bounding both the response and the sort before it.
Next.js with Validation
// SECURE - Next.js API route with validation
// pages/api/users.js
import { MongoClient } from 'mongodb';
const client = new MongoClient(process.env.MONGODB_URI);
const ALLOWED_ROLES = ['user', 'moderator', 'admin'];
function validateRole(role) {
// SECURE - a repeated ?role=a&role=b gives an array, which is not a string
if (typeof role !== 'string') {
throw new Error('Role must be a string');
}
if (!ALLOWED_ROLES.includes(role)) {
throw new Error('Invalid role');
}
return role;
}
function validateUsername(username) {
if (typeof username !== 'string') {
throw new Error('Username must be a string');
}
// SECURE - reject an overlong value; truncating it would search for a
// different username than the caller asked for, and say nothing about it
if (!/^[a-zA-Z0-9_]{3,50}$/.test(username)) {
throw new Error('Invalid username format');
}
return username;
}
export default async function handler(req, res) {
const { method, query } = req;
if (method !== 'GET') {
return res.status(405).end();
}
try {
const db = client.db('app');
// SECURE - Build validated query
const safeQuery = {};
if (query.role !== undefined) {
safeQuery.role = validateRole(query.role);
}
if (query.username !== undefined) {
safeQuery.username = validateUsername(query.username);
}
const users = await db.collection('users')
.find(safeQuery)
.limit(100)
.toArray();
res.json(users);
} catch (error) {
console.warn('user query rejected', { reason: error.message });
res.status(400).json({ error: 'Invalid request' });
}
}
Why this works: safeQuery starts empty and gains only keys this function writes, so no request field decides which field is filtered. Each value goes through a validator that either returns a string or throws, and the catch turns a throw into a 400.
The catch is wider than the validators, though, so what it answers with matters. db.collection('users').find(...).toArray() is inside it, and a driver failure arrives as connect ECONNREFUSED 10.1.2.3:27017 - an internal host and port, returned to whoever sent the request. The detail belongs in the log; the caller gets a fixed Invalid request.
Both validators reject rather than skip, which is the part that is easy to get wrong here. Writing the username branch as if (query.username && typeof query.username === 'string') looks equivalent and is not: a repeated parameter arrives as an array (?username=a&username=b), the guard is false, and the field is quietly left out - so the endpoint answers 200 with the query it was able to build rather than the query it was asked for. Silently widening a filter is the failure direction nobody tests for, and it looks identical to success.
Truncating with substring(0, 50) has the same shape one level down: it does not reject the overlong value, it searches for a different one. Reject it and let the caller see a 400.
Testing
To verify NoSQL injection protection:
- Operator injection through the body: POST
{"username": "admin", "password": {"$ne": null}}and assert a 400, not a 200. Test the body specifically - on Express 5 the query-string form of the same payload arrives as the literal keyusername[$ne]and passes whether or not the bug is fixed. - Operator injection through the query string, on the version you deploy: send
?username[$ne]=adminand assert a 400. If the app setsquery parsertoextended, or runs Express 4, this reaches the filter as an object; if it does not, record that and move the test to the body. - The allowlisted filter still filters: request
?price_min=20against a fixture holding items priced 10 and 30, and assert exactly one result. A filter that drops its conditions still returns products and still passes every injection test. - A bad value fails loudly: send
?price_min=abcand assert a 400, not a silently unfiltered result set. Send?username=a&username=bto the Next.js route and assert a 400 as well - repeating the key is what produces an array, and an array reaching atypeof x === 'string'guard is the case that gets dropped instead of rejected. Note the payload:?username[]=a&username[]=bdoes not test this, because Next parses the bracket literally and the key becomesusername[], leavingquery.usernameundefined and the branch unvisited. - An unlisted field is ignored, and that is deliberate: send
?utm_source=xand assert a normal 200. Query strings collect parameters the API never defined, so the allowlist drops what it does not know rather than rejecting the request. Assert it drops them rather than passing them through - the failure to catch isutm_sourcereaching the filter, not the 200. - The unknown-user path costs what the known-user path costs: time 20 logins for an existing username with a wrong password and 20 for a username that does not exist. The medians should be within noise; a difference of more than a few milliseconds means a branch is returning before
bcrypt.compare. sanitizeFilteris actually on: assert thatModel.findOne({ username: { $ne: 'x' } })returns nothing. A schema alone does not make it so, and nothing fails loudly when it is off.$wherehas no untrusted input: grep for it and confirm each occurrence is built from literals. Nothing about a$wherestring is checkable at runtime.- A rejection body says nothing: stop the database (or point the client at a closed port) and send a valid request. Assert the response body is the fixed
Invalid requestand contains no host, port or driver text -connect ECONNREFUSEDin a 400 is the assertion failing. The validation tests above pass either way, because they never reach the driver.
Common Pitfalls
- Mongoose schema field types (for example
username: String) only cast values during document construction and.save()- thesanitizeFilteroption that neutralises$-prefixed keys in query filters is still off by default in Mongoose 9 (mongoose.get('sanitizeFilter')returnsundefined), so a filter object built straight fromreq.bodyand passed toModel.find()can still carry an operator like$neunlesssanitizeFilter: trueis set globally or per query. Note what it does when on: it wraps the object value in$eqrather than stripping the key, so{username: {$ne: 'admin'}}becomes a search for a stored value that is literally that object. express-mongo-sanitize(a commonly added middleware for this exact CWE) mutatesreq.querydirectly, which breaks silently, or throws, on Express 5, wherereq.querybecame a read-only getter; teams that added the middleware under Express 4 can upgrade Express and keep the middleware in their stack without realizing it has stopped sanitizing anything.- The allowlist-and-type-check pattern shown above protects
req.query, but the same handler (or a sibling POST/PATCH endpoint) that accepts a JSON body viareq.bodyneeds the identical field-and-type check applied separately -express.json()parses the body into real nested objects, not the string-only key/value pairsreq.queryproduces, so a filter built fromreq.bodywithout its own allowlist pass is exploitable even when the query-string path is fixed.