MongoDB stores JavaScript objects. Your query is already structured data — there is no "query string" to inject into. Which is exactly why NoSQL injection looks different from SQL injection, and why generic security linters miss it.
The attack isn't ; DROP TABLE users; --. It's this:
// POST body: { "username": "admin", "password": { "$ne": null } }
await db.collection("users").findOne({
username: req.body.username,
password: req.body.password, // ← operator injection bypasses auth
});
Generic linters read your query as an ordinary object literal — they have no model of what .findOne() does. eslint-plugin-mongodb-security models the MongoDB query API directly, which is the entire reason it catches this and they don't. As far as I know it's the only ESLint plugin that does. (Disclosure: I maintain it.) Let's walk the bypass end to end, then wire up the rules.
Walking the bypass, object by object
-
The request. An attacker POSTs
{ "username": "admin", "password": { "$ne": null } }— valid JSON, valid request body. No special characters, nothing a WAF flags. -
The query object. Express parses it, so
req.body.passwordis now the object{ $ne: null }, not a string. Your handler buildsUser.findOne({ username: req.body.username, password: req.body.password })— which becomesfindOne({ username: "admin", password: { $ne: null } }). -
What MongoDB does.
{ $ne: null }matches any document whosepasswordis not null — i.e. every user. MongoDB returns the first match, and since the attacker namedadmin, that's the row they get. No password was ever compared, and your logs show a clean, "successful" login. Authentication bypassed with zero credentials, no trace. -
What the rule sees.
no-unsafe-queryflags the member expressionreq.body.passwordflowing unsanitized into a query-execution call (.findOne()) as a field value — the exact spot where an object can smuggle a$-operator past a check that assumed a string. -
Why generic linters walk past it. To
eslint-plugin-security,findOne({ password: req.body.password })is an ordinary property assignment — noeval, no string concatenation, no taint sink it recognizes. The danger is invisible unless the linter knows.findOne()is a query sink and that an object-typed value there can carry operators. That domain model is the whole reason a MongoDB-specific plugin exists.
The fix is to never let a request value reach a query as a raw object — compare a hashed password separately (shown under rule 2 below).
Install
# npm
npm install --save-dev eslint-plugin-mongodb-security
# yarn
yarn add --dev eslint-plugin-mongodb-security
# pnpm
pnpm add --save-dev eslint-plugin-mongodb-security
eslint.config.mjs:
import { configs } from "eslint-plugin-mongodb-security";
export default [
// `recommended` enables the full rule set and registers the plugin.
// `flagship` is a single-rule starter (no-unsafe-query only); `strict` makes everything an error.
configs.recommended,
];
Compatibility. Node ≥ 18 · ESLint 8, 9, or 10 (flat config) · optional
mongodb/mongoosepeers. On Oxlint? The plugin ships an/oxlintentry —{ "jsPlugins": ["eslint-plugin-mongodb-security/oxlint"] }.
The three rules you need most
1. no-unsafe-query — NoSQL operator injection (CWE-943, CVSS 9.8)
Fires when user input reaches a query method (find, findOne, updateMany, …) as a field value — the broad NoSQL-injection sink. (The $where/$expr/$function-specific variant is no-unsafe-where, also in the table below.)
// ❌ Flagged — $where with user-controlled JavaScript
db.collection("orders").find({
$where: `this.total > ${req.query.minTotal}`,
});
// ✅ Safe — use $gt instead of $where
db.collection("orders").find({
total: { $gt: Number(req.query.minTotal) },
});
2. no-operator-injection — User input under a query operator (CWE-943, CVSS 9.1)
Fires when user input flows into the value of a query operator ($gt, $ne, $in, $where, …). An attacker who controls that value can widen or invert the match — or pass an object where you expected a scalar. (The auth-bypass walked through above — req.body.password reaching the query as a raw value — is caught by no-unsafe-query; the fix there is to hash and compare separately: await bcrypt.compare(req.body.password, user.passwordHash).)
// ❌ Flagged — request value lands directly under a query operator
db.collection("orders").find({ total: { $gt: req.query.min } });
// ✅ Safe — validate to a primitive first; never pass the raw request value
const min = Number.parseInt(req.query.min, 10);
if (Number.isNaN(min)) throw new BadRequestError();
db.collection("orders").find({ total: { $gt: min } });
3. no-hardcoded-connection-string — Credentials in source (CWE-798, CVSS 7.5)
Detects mongodb:// and mongodb+srv:// connection strings with embedded credentials in source code. These get committed to git history and exposed in build artifacts.
// ❌ Flagged — credentials in source
const client = new MongoClient(
"mongodb+srv://admin:hunter2@cluster0.example.com/mydb"
);
// ✅ Safe — from environment variable
const client = new MongoClient(process.env.MONGODB_URI);
Try it yourself
Save this as vuln.js and run npx eslint vuln.js with the recommended config above:
const { MongoClient } = require("mongodb");
// no-hardcoded-connection-string:
const client = new MongoClient("mongodb+srv://admin:hunter2@cluster0.example.com/app");
async function login(req, db) {
// no-unsafe-query: req.body.password can arrive as { $ne: null }
return db.collection("users").findOne({ email: req.body.email, password: req.body.password });
}
async function search(req, db) {
// no-operator-injection: request value under a $-operator
return db.collection("orders").find({ total: { $gt: req.query.min } });
}
Run it and you'll get four errors and one warning: the hardcoded connection string (1); the login line trips no-unsafe-query twice — once per request value, email and password (2); the $gt operator-injection (1); plus a no-unbounded-find warning on the .find() that has no .limit(). Zero findings means the config isn't wired (check you're on ESLint flat config).
Now point it at your own repo. If it flags a real query you've shipped, that's the plugin earning its keep — tell me which rule caught you in the comments.
Why a MongoDB-specific plugin
eslint-plugin-security and eslint-plugin-sonarjs catch the JavaScript-level patterns — eval, unsafe regex, child_process. They operate one layer above the MongoDB query API, so to them db.collection("users").find({ $where: userInput }) reads as an ordinary object literal. Run them alongside this plugin — it adds the MongoDB layer they don't model, knowing:
- Which methods are query execution points (
.find(),.findOne(),.aggregate(),.updateMany(), etc.) - Which operators are dangerous (
$where,$expr,$function,$accumulator) - What constitutes user input in the MongoDB context
All 16 rules
| Rule | CWE |
|---|---|
no-unsafe-query |
CWE-943 |
no-operator-injection |
CWE-943 |
no-hardcoded-connection-string |
CWE-798 |
no-hardcoded-credentials |
CWE-798 |
require-tls-connection |
CWE-295 |
require-auth-mechanism |
CWE-287 |
no-unsafe-regex-query |
CWE-400 |
no-unsafe-where |
CWE-943 |
no-debug-mode-production |
CWE-489 |
require-schema-validation |
CWE-20 |
no-select-sensitive-fields |
CWE-200 |
no-bypass-middleware |
CWE-284 |
no-unsafe-populate |
CWE-943 |
no-unbounded-find |
CWE-400 |
require-projection |
CWE-200 |
require-lean-queries |
CWE-400 |
Each rule's CWE and CVSS base score come from its own metadata — the per-rule docs carry the scoring vector. Severity depends on the config you enable (recommended vs strict).
What's the worst NoSQL injection you've shipped — or caught in review? Drop the payload in the comments — I read every one, and the gnarliest ones become new rules. If this plugin earns a place in your CI, ⭐ star the repo — stars are the signal that keeps the 16 rules maintained and growing.
Top comments (0)