NoSQL injection¶
When is it relevant¶
A new or changed code path that uses request input in a NoSQL query or update: MongoDB filters, $where, aggregation pipelines, ORM where objects, Elasticsearch or Redis commands. It's most likely when req.body or req.query values reach the query without a check that they're plain strings or numbers.
Attack¶
Express parses ?password[$ne]=x and JSON bodies into objects. A filter like { password: { $ne: null } } then matches any record, which bypasses login checks. Other variants:
$regexenumerates values one character at a time.$whereor$functionrun JavaScript on the database.- Update operators such as
$setchange fields the route never meant to touch.
Mitigation¶
- Validate input types with a schema, or coerce values to strings before querying.
- Reject keys that start with
$or contain., for example with MongoosesanitizeFilter. - Build filters and updates from allowlisted fields. Never pass input to
$where.
Examples¶
Mitigation options
| Option | When it fits | Docs |
|---|---|---|
| Cast and validate types before querying | Always; stops operator objects at the edge | Zod |
Compare with $eq explicitly |
Raw driver queries | Mongoose queries |
Strip $-prefixed keys |
Defence in depth across many routes | express-mongo-sanitize |
Vulnerable — operator object where a string was expected
router.post('/login', async (req, res) => {
const user = await User.findOne({
email: req.body.email,
password: req.body.password, // {"$ne": null} matches the first user
})
if (!user) return res.sendStatus(401)
req.session.userId = user._id
res.json({ ok: true })
})
{"email": {"$gt": ""}, "password": {"$ne": null}} logs in as whoever comes
first.
Vulnerable — query string builds an object
// GET /items?id[$ne]=000000000000000000000000
const item = await Item.findOne({ _id: req.query.id, ownerId: req.user.id })
Express's query parser turns id[$ne]=... into a nested object before your code
sees it.
Vulnerable — server-side JavaScript
Mitigated — validate and cast
const Credentials = z.object({
email: z.string().email(),
password: z.string().min(8).max(200),
}).strict()
const parsed = Credentials.safeParse(req.body)
if (!parsed.success) return res.sendStatus(400)
const user = await User.findOne({ email: parsed.data.email })
const ok = user && await bcrypt.compare(parsed.data.password, user.passwordHash)
if (!ok) return res.sendStatus(401)
The password is never part of the query: it is compared as a string after the user is loaded, so an operator object has nothing to act on.
Mitigated — force equality
const item = await Item.findOne({
_id: { $eq: String(req.query.id) },
ownerId: { $eq: req.user.id },
})
Docs: Mongoose queries
Mitigated — strip operators globally
Docs: express-mongo-sanitize
Bypasses to check
- A sanitizer mounted after the routes, or after a body parser that already handed the object to a handler.
- On Express 5,
req.queryis a getter and cannot be reassigned, so middleware that rewrites it may silently do nothing — check it actually runs (Express 5 API). String(x)on an object yields"[object Object]", which looks safe but turns a match into a miss; arrays (id=1&id=2) stringify to"1,2".$regexbuilt from input: catastrophic backtracking, or.*matching every record; an anchored^alone doesn't fix it.- Operators inside
$lookup,$match,aggregatepipelines,updateOneupdate documents ({"$set": {"role": "admin"}}), and$expr. - Check then change: the value is validated as a string, then the raw body value is used in the query.
- Mongoose casting hides the problem on typed paths but not on
Mixed,Object, or.lean()raw driver calls.
What to grep for
findOne(,find(,updateOne(,aggregate(,$where,$regex,$exprreq.bodyorreq.queryplaced directly into a filter objectmongoSanitize,sanitizeFilter,strictQuery