DEV Community

Ofri Peretz
Ofri Peretz

Posted on • Edited on • Originally published at ofriperetz.dev

One JSON Object Can Log Into Your MongoDB App as Admin. Here's the ESLint Rule That Catches It.

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
});
Enter fullscreen mode Exit fullscreen mode

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

  1. The request. An attacker POSTs { "username": "admin", "password": { "$ne": null } } — valid JSON, valid request body. No special characters, nothing a WAF flags.
  2. The query object. Express parses it, so req.body.password is now the object { $ne: null }, not a string. Your handler builds User.findOne({ username: req.body.username, password: req.body.password }) — which becomes findOne({ username: "admin", password: { $ne: null } }).
  3. What MongoDB does. { $ne: null } matches any document whose password is not null — i.e. every user. MongoDB returns the first match, and since the attacker named admin, 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.
  4. What the rule sees. no-unsafe-query flags the member expression req.body.password flowing 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.
  5. Why generic linters walk past it. To eslint-plugin-security, findOne({ password: req.body.password }) is an ordinary property assignment — no eval, 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
Enter fullscreen mode Exit fullscreen mode

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,
];
Enter fullscreen mode Exit fullscreen mode

Compatibility. Node ≥ 18 · ESLint 8, 9, or 10 (flat config) · optional mongodb/mongoose peers. On Oxlint? The plugin ships an /oxlint entry — { "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}`,
});
Enter fullscreen mode Exit fullscreen mode
// ✅ Safe — use $gt instead of $where
db.collection("orders").find({
  total: { $gt: Number(req.query.minTotal) },
});
Enter fullscreen mode Exit fullscreen mode

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 } });
Enter fullscreen mode Exit fullscreen mode
// ✅ 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 } });
Enter fullscreen mode Exit fullscreen mode

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"
);
Enter fullscreen mode Exit fullscreen mode
// ✅ Safe — from environment variable
const client = new MongoClient(process.env.MONGODB_URI);
Enter fullscreen mode Exit fullscreen mode

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 } });
}
Enter fullscreen mode Exit fullscreen mode

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.


npm · Rule docs · ⭐ GitHub

Top comments (0)