Software Securityhome
Fix a findingSearch
Fix card · A05:2025 Injection

Fix NoSQL operator injection in Node.js and Express

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

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. Blocklisting $ne or $where fails, because dozens of other operators match too.

On this page

Steps

  1. Find every MongoDB call whose filter or update takes request data: find, findOne, findOneAndUpdate, updateOne, deleteMany, countDocuments and aggregate with req.body, req.query, req.params or headers, including in the data-access modules your routes call.
  2. Check the shape of the input before the query: each value must be a string (or a number) of a sane length; answer 400 for objects, arrays, null and booleans. A schema validator such as zod with z.string() does the same job.
  3. Write filters as { field: { $eq: value } }, so that even a value that slipped past validation is compared literally. With Mongoose, mongoose.set('sanitizeFilter', true) wraps $-prefixed objects in $eq for you.
  4. Look an account up by its identifier and compare secrets (reset tokens, invitation codes, API keys) in your code with crypto.timingSafeEqual, then consume the token atomically with updateOne filtered on _id and the token.
  5. Keep Express 5's defaults: the 'simple' query parser and express.urlencoded({ extended: false }). With the extended parser (qs), ?token[$ne]=x arrives as an object as well.
  6. If nothing uses $where, $function or $accumulator, turn server-side JavaScript off with security.javascriptEnabled: false (mongod --noscripting).
  7. Add regression tests that send operator payloads ($ne, $gt, $gte, $regex, $exists, $nin, $type, $in) to a real mongod and check that nothing changes.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableimpl/vulnerable.jsJavaScript
export async function resetPassword(users, { email, token }, newPasswordHash, now) {
  const user = await users.findOneAndUpdate(
    { email, resetToken: token, resetExpires: { $gt: now } },
    { $set: { passwordHash: newPasswordHash }, $unset: { resetToken: '', resetExpires: '' } },
  );
  return user !== null;
}

With IMPL=vulnerable, 9 of 16 tests fail (9 exploit tests).

The fix

Fixedimpl/fixed.jsJavaScript
const digest = (value) => createHash('sha256').update(value).digest();
// Hashing first gives timingSafeEqual two buffers of the same length.
const sameSecret = (a, b) => timingSafeEqual(digest(a), digest(b));

export async function resetPassword(users, { email, token }, newPasswordHash, now) {
  // 1. A JSON value can be an object, an array, a number or null: accept strings only.
  if (typeof email !== 'string' || typeof token !== 'string') return false;
  if (email.length > 254 || token.length > 128) return false;

  // 2. Look the account up by email alone, with $eq, so the value is always a literal.
  const user = await users.findOne({ email: { $eq: email } });

  // 3. Check expiry and the token in code; the database never evaluates the caller's token.
  if (!user || typeof user.resetToken !== 'string' || !(user.resetExpires > now)) return false;
  if (!sameSecret(token, user.resetToken)) return false;

  // 4. Consume the token atomically: a replay or a parallel request updates nothing.
  const { modifiedCount } = await users.updateOne(
    { _id: user._id, resetToken: { $eq: token } },
    { $set: { passwordHash: newPasswordHash }, $unset: { resetToken: '', resetExpires: '' } },
  );
  return modifiedCount === 1;
}

With IMPL=fixed, 16 of 16 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.jsJavaScript
const BLOCKED_OPERATORS = ['$ne', '$gt', '$regex', '$where'];

function containsBlockedOperator(value) {
  return (
    typeof value === 'object' &&
    value !== null &&
    Object.keys(value).some((key) => BLOCKED_OPERATORS.includes(key))
  );
}

export async function resetPassword(users, { email, token }, newPasswordHash, now) {
  if (containsBlockedOperator(email) || containsBlockedOperator(token)) return false;
  const user = await users.findOneAndUpdate(
    { email, resetToken: token, resetExpires: { $gt: now } },
    { $set: { passwordHash: newPasswordHash }, $unset: { resetToken: '', resetExpires: '' } },
  );
  return user !== null;
}

With IMPL=wrong, 6 of 16 tests fail (6 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtest/reset.test.jsJavaScript
describe(`exploit (IMPL=${implName})`, () => {
  // The attacker knows Alice's email address but has never seen the reset email. Each payload
  // must be refused, and no account may change.
  async function assertRefused(body) {
    const before = await snapshot();
    const res = await post('/reset-password', { password: ATTACKER_PASSWORD, ...body });
    assert.equal(res.status, 400, `reset accepted: ${JSON.stringify(res.body)}`);
    assert.deepEqual(await snapshot(), before, 'an account changed');
    assert.equal((await signIn(alice.email, ATTACKER_PASSWORD)).status, 401);
  }

  test('{"$ne": null} as the token does not mean "any token"', () =>
    assertRefused({ email: alice.email, token: { $ne: null } }));

  test('{"$gt": ""} as the token does not match every string', () =>
    assertRefused({ email: alice.email, token: { $gt: '' } }));

  test('{"$regex": "^K"} cannot guess the token one character at a time', () =>
    assertRefused({ email: alice.email, token: { $regex: '^K' } }));

  test('{"$exists": true} as the token is refused', () =>
    assertRefused({ email: alice.email, token: { $exists: true } }));

  test('{"$gte": ""} as the token is refused', () =>
    assertRefused({ email: alice.email, token: { $gte: '' } }));

  test('{"$nin": []} as the token is refused', () =>
    assertRefused({ email: alice.email, token: { $nin: [] } }));

  test('{"$type": "string"} as the token is refused', () =>
    assertRefused({ email: alice.email, token: { $type: 'string' } }));

  test('{"$in": [...]} cannot try many guesses in one request', () =>
    assertRefused({ email: alice.email, token: { $in: ['guess-1', 'guess-2', alice.resetToken] } }));

  test('operators in both fields cannot reset an account whose email the attacker does not know', () =>
    assertRefused({ email: { $gte: '' }, token: { $exists: true } }));
});

Pitfalls

  • Blocklists of the operators in the write-ups ($ne, $gt, $regex, $where) miss $gte, $exists, $nin, $type and $in, which match just as well: this card's wrong fix fails six exploit tests that way.
  • $in turns one request into many guesses: {"$in": [guess1, guess2, ...]} tests a whole list of tokens at once, and $regex anchored with ^ leaks a secret one character at a time.
  • express.json() hands you whatever JSON the client sent. Destructuring const { token } = req.body does not make token a string.
  • String(value) turns an object into "[object Object]", which stops the operator but hides the bug; validate the type and answer 400 instead.
  • $where, $function and $accumulator run JavaScript on the database server, and MongoDB enables server-side scripting by default ($where ran on mongod 8.0.32 here). They are reachable when the request controls a whole filter, as in collection.find(req.body.filter).
  • An unknown operator such as {"$foo": 1} makes the driver throw a MongoServerError; catch it and return a generic error rather than its message.
  • OWASP's Forgot Password Cheat Sheet asks for reset tokens that are random, long, stored securely, single use and short-lived. This card stores the token as the vulnerable app does; storing a SHA-256 hash of it instead keeps a database leak from handing out working reset links.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs nosql-injection/node-express does this for you).
  2. Against your own endpoint, post {"email": "<a test account>", "token": {"$ne": null}, "password": "..."}, then the same with {"$gte": ""} and {"$exists": true}, and expect 400 with the account unchanged.
  3. Static analysis is not a control here: the Semgrep community packs have no JavaScript MongoDB injection rule, and CodeQL's js/sql-injection, which models the mongodb driver, was not run on this layout. The regression tests against a real mongod are the evidence.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix NoSQL operator injection in Node.js and Express
ToolRuleWhat it found here
Semgrepp/default, p/javascript, p/nodejs, p/security-audit, p/owasp-top-tenNo findings on any implementation. On a single-file probe whose Express handlers passed req.body values, a whole req.body.filter object and a $where string built from req.body straight to collection.findOne() and find(), the same packs found nothing either: the only MongoDB injection rule in the community registry is for Java (java.mongodb.security.injection.audit.mongodb-nosqli).Checked: Semgrep 1.180.0 (CE); semgrep scan --config p/default --config p/javascript --config p/nodejs --config p/security-audit --config p/owasp-top-ten app.js impl lib
CodeQLjs/sql-injectionThis query also covers NoSQL injection (it is tagged CWE-943 and merges NosqlInjectionQuery). Its MongoDB model treats the arguments of mongodb Collection methods as query sinks and tracks a request value as a tainted object; typeof x === 'string' checks are barriers (TaintedObject.qll), so the fixed code should be clean and the vulnerable code a finding. The blocklist function in the wrong code is not a recognised barrier. Whether CodeQL links the route in app.js to impl/*.js through the dynamic import was not tested.Checked: github/codeql main at b5b8165; not executed

Libraries and versions

Libraries and versions
ComponentVersion
LanguageJavaScript (ES modules)
RuntimeNode.js 26.11.1
FrameworkExpress 5.3.0 with the MongoDB Node.js driver 7.7.0
express5.3.0
mongodb (the official MongoDB Node.js driver)7.7.0
MongoDB server (mongod, started by the tests)8.0.32
mongodb-memory-server (test only)11.3.0
node:crypto (scrypt, timingSafeEqual; built in)Node.js 26.11.1

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the nosql-injection/node-expressfolder.

Shell
npm ci
Shell
# Expected: every test passes
IMPL=fixed npm test --silent

# Expected: an exploit test fails
IMPL=vulnerable npm test --silent

# Expected: a test fails
IMPL=wrong npm test --silent

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs nosql-injection/node-express runs all three modes and checks the expected results.

Download the project ZIP, 33 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • node 26.11.1
  • npm 11.20.0
  • express 5.3.0
  • mongodb 7.7.0
  • mongod 8.0.32 (mongod-arm64-darwin, downloaded by mongodb-memory-server 11.3.0)
  • semgrep 1.180.0
Matrix
fixed: 16 of 16 tests pass; vulnerable: 9 of 16 tests fail (9 exploit tests); wrong: 6 of 16 tests fail (6 exploit tests)
Test results
fixed 16/16 pass; vulnerable 9/16 fail (9 exploit tests); wrong 6/16 fail (6 exploit tests: $exists, $gte, $nin, $type, $in and operators in both fields), offline after npm ci
Dependency freshness
express 5.3.0 was published on 2026-10-09 as a security release (qs, body-parser and proxy-addr updates), mongodb 7.7.0 on 2026-09-28, mongodb-memory-server 11.3.0 on 2026-09-21; MongoDB 8.0.32 was released on 2026-09-03 and is the latest 8.0 patch (mongodb-memory-server's own default, 8.2.6, is several patches old, so package.json config pins the server version). npm's postinstall step of mongodb-memory-server downloads mongod into node_modules/.cache/mongodb-binaries (allowScripts approves it), so test runs need no network and the card's download never contains the binary; the matrix also passed in a sandbox that blocked every non-loopback connection. npm audit found 0 vulnerabilities and npm audit signatures verified all 113 packages. Rerun on Node.js 26.11.1, above the 26.5.1 security floor in engines.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP NoSQL Security Cheat Sheet
  2. OWASP WSTG: Testing for NoSQL Injection
  3. MongoDB manual: $eq (implicit and explicit equality)
  4. MongoDB manual: $where and server-side scripting
  5. MongoDB manual: server-side JavaScript (security.javascriptEnabled, --noscripting)
  6. Node.js: crypto.timingSafeEqual()
  7. Express: Migrating to Express 5 (simple query parser, urlencoded extended false)
  8. Mongoose: sanitizeFilter()
  9. OWASP Forgot Password Cheat Sheet
  10. mongodb-memory-server: configuration (version, downloadDir)
  11. CWE-943: Improper Neutralization of Special Elements in Data Query Logic
  12. CAPEC-676: NoSQL Injection

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.