Choose your stack
Fix NoSQL operator injection in Node.js and Express
Never let a request value reach a MongoDB filter as an object: accept strings only (typeof value === 'string'), compare with { field: { $eq: value } }, and check secrets such as reset tokens in code with timingSafeEqual after looking the account up by email.
Open the fix card Python · FlaskFix NoSQL operator injection in Python and Flask
Never let a request value reach a PyMongo filter as a dict: accept str only (isinstance(value, str)), compare with {"field": {"$eq": value}}, and check secrets such as reset tokens in code with hmac.compare_digest after looking the account up by email.
Open the fix cardStacks without a card
- Java and Spring Boot: no card yet. The nearest is Fix NoSQL operator injection in Node.js and Express.
- C# and ASP.NET Core: no card yet. The nearest is Fix NoSQL operator injection in Node.js and Express.
- TypeScript and Next.js: no card yet. The nearest is Fix NoSQL operator injection in Node.js and Express, in the same language.
- Python and FastAPI: no card yet. The nearest is Fix NoSQL operator injection in Python and Flask, in the same language.
- Python and Django: no card yet. The nearest is Fix NoSQL operator injection in Python and Flask, in the same language.
- Go and net/http: no card yet. The nearest is Fix NoSQL operator injection in Node.js and Express.
- Ruby on Rails: no card yet. The nearest is Fix NoSQL operator injection in Node.js and Express.
- PHP and Laravel: no card yet. The nearest is Fix NoSQL operator injection in Node.js and Express.
How it happens
- JSON parsers return objects, arrays, numbers and null as well as strings. express.json(), Flask's request.get_json() and every other JSON body parser hand the application whatever the client typed, and destructuring or indexing the body does not change the type.
- The value goes into a filter such as {email, resetToken: token}. MongoDB reads {"$ne": null} as "not null", {"$gt": ""} and {"$gte": ""} as "any non-empty string", {"$exists": true}, {"$type": "string"} and {"$nin": []} as "whatever is stored", and {"$regex": "^K"} as a prefix test that leaks a secret one character at a time; {"$in": [...]} tries a whole list of guesses in one request.
- Operators in an identifier field are worse: {"email": {"$gte": ""}} matches the first account in the collection, so the attacker does not even need to know whose password they are resetting.
- Query strings can do it too: Express 4's default extended parser and qs turn ?token[$ne]=x into {token: {$ne: "x"}}. Express 5 changed the default to the simple parser, but many apps switch the extended one back on.
- A filter object passed through whole from the request, as in collection.find(req.body.filter), also hands over $where, $function and $accumulator, which run JavaScript on the database server; MongoDB enables server-side scripting by default.
- Hand-made fixes blocklist the operators in the write-ups people have read ($ne, $gt, $regex, $where) and miss the dozens of others that match just as well.
Fix principles
- Validate the shape of every value before it reaches a query: a string (or number) of a bounded length, never an object or array. Answer 400 otherwise.
- Compare with an explicit $eq ({email: {$eq: email}}), so that a value that slipped past validation is still compared literally. Mongoose's sanitizeFilter option does this for you.
- Do not let the database check secrets. Look the account up by its identifier, then compare reset tokens, invitation codes and API keys in code with a constant-time comparison, and consume single-use tokens atomically.
- Never pass a client-supplied object as a whole filter, projection, sort or update. Build those from an allowlist of fields and operators.
- Turn server-side JavaScript off (security.javascriptEnabled: false) when nothing needs $where, $function or $accumulator.
- Do not blocklist operators or strip $ keys and hope; reject what is not of the expected type.
The scenario
Every card runs the same password-reset endpoint: POST /reset-password takes JSON {email, token, password}; the reset email gave the user a random token that is valid for 30 minutes. The app answers 200 when the password changed and the same 400 for an unknown email, a wrong token and an expired one; POST /login lets the tests check who can sign in afterwards. Alice and Carol have pending resets, Bob's expired an hour ago and Dave has none. The attacker knows Alice's email address but never saw her reset email. The Node.js card runs the MongoDB Node.js driver against a real mongod 8.0.32; the Flask card runs PyMongo's API against mongomock, an in-process MongoDB double, with payloads limited to operators it evaluates the way mongod does (checked against mongod 8.0.32 with PyMongo).
How the cards prove it
- Run each card's tests in three modes: the fixed implementation passes, and the vulnerable and plausible-but-wrong implementations fail. run-all.mjs does this for every card.
- Nine exploit tests send Alice's email with a token of {"$ne": null}, {"$gt": ""}, {"$regex": "^K"}, {"$exists": true}, {"$gte": ""}, {"$nin": []}, {"$type": "string"} and {"$in": [...]}, and one sends operators in both fields; each must get 400 with no account changed and the attacker's password unusable. Seven functional tests keep a genuine reset, single use, wrong and expired tokens, an unknown email, an address with + in it and an operator-looking password working.
- The wrong implementation blocks $ne, $gt, $regex and $where, so it passes three exploit tests and fails the six that use other operators.
- Static analysis is weak here. The Semgrep community registry has a MongoDB injection rule only for Java, and the packs found nothing even on single-file probes; CodeQL's js/sql-injection and py/nosql-injection model the mongodb driver and PyMongo but were not run, and mongomock is not modelled. Each card's scannerRules records what was checked.
Identifiers
CWE-943 (Improper Neutralization of Special Elements in Data Query Logic) is MITRE's entry for injection into query languages other than SQL, and CAPEC-676 (NoSQL Injection) is the matching attack pattern. The site's OWASP Top 10:2025 data does not list CWE-943 among A05 Injection's mapped CWEs, so these cards name A05 directly. ASVS 5.0.0 1.2.4 asks for parameterized or otherwise protected queries and names NoSQL explicitly; 15.3.5 asks the application to check that variables have the expected type, which is what stops an object from standing in for a string.
Attack patterns:
References
Guides on this weakness
Keep untrusted values out of executable syntax
Choose a safe interface for each interpreter and bound the work performed by parsers and converters.
Prevent NoSQL, LDAP, XPath and expression-language injection
SQL is not the only query language that receives user input. Stop MongoDB operator injection with type checks and $eq, escape LDAP filter values, pass XPath values as variables, and never evaluate SpEL or OGNL built from request data.