Choose your stack

Stacks without a card

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

  1. OWASP NoSQL Security Cheat Sheet
  2. OWASP WSTG: Testing for NoSQL Injection
  3. CWE-943: Improper Neutralization of Special Elements in Data Query Logic
  4. CAPEC-676: NoSQL Injection
  5. MongoDB manual: query predicates ($eq)
  6. MongoDB manual: server-side JavaScript
  7. OWASP Forgot Password Cheat Sheet

Guides on this weakness