NoSQL Injection: Why MongoDB, Couchbase, and Firebase Apps Still Leak Data
The most common NoSQL injection a code reviewer finds in 2026 is six characters wide. A junior engineer writes a Mongoose login handler that pulls username and password directly out of the request body and passes them as a filter to User.findOne. The endpoint has unit tests, the staging environment behaves correctly, and the developer ships it on a Tuesday. On Wednesday an attacker sends {"username": {"$ne": null}, "password": {"$ne": null}} as the JSON body, MongoDB cheerfully returns the first user in the collection whose username and password are both non-null — which is everyone — and the attacker is logged in as the most senior administrator the database happens to enumerate first. The bug has a name, has been documented since at least 2010, has appeared in CVE filings against Rocket.Chat, Mongoose-based starters, NoSQL admin tools, and every authentication boilerplate that has copied the pattern from Stack Overflow. The fix is one line. The reason it persists is the same reason XXE persists: nobody owns the seam where untyped JSON enters the application and exits as a database filter.
NoSQL injection sits in OWASP A03 alongside its older SQL cousin, but the bug class behaves differently because the attacker is not smuggling a string into a parser — the attacker is replacing a string with an object that the database treats as an operator expression. This article walks through what NoSQL injection actually is at the query-builder level, what vulnerable and fixed code looks like in Express, Mongoose, and Firebase, why the bug class is endemic to JavaScript backends, and what to do about it before the next admin login form ships with the same six-character backdoor.
What NoSQL Injection Actually Is
MongoDB, Couchbase, Firebase, and most modern document stores accept their queries as structured objects rather than as parsed strings. A Mongoose find call takes a JavaScript object whose keys are field names and whose values are either literal matches or operator expressions like {"$ne": null}, {"$gt": 0}, {"$regex": "^admin"}, or — on older or misconfigured instances — {"$where": "function() { return true; }"}. The injection primitive is not string concatenation; it is type confusion. The endpoint expects a string for the username field, the request body delivers an object, and the query builder dutifully forwards that object straight into the database driver. The driver sees an operator expression, the database evaluates it as one, and the filter that was supposed to match a single user matches every user.
The same primitive scales across the operator catalog. $ne bypasses equality checks by matching anything except the supplied value. $gt with an empty string matches every non-empty string in the collection. $regex turns an authentication probe into a side-channel that lifts the password character by character through response timing. $where on legacy MongoDB deployments evaluates an attacker-controlled JavaScript expression on the server, which on configurations that have not disabled the operator gives the attacker arbitrary code execution against the database process. The Petya Tsenova talks documented in HackTricks catalogue more than a dozen variants beyond these, including blind boolean and time-based exfiltration patterns that mirror the SQL-injection playbook one operator at a time. The common ingredient is a server that accepts JSON, parses it into a JavaScript object, and forwards the object into a query without enforcing the type the schema expects.
The Mongoose Login Handler That Logs Anyone In
The textbook vulnerable shape sits in roughly half the Express + Mongoose authentication tutorials published in the past decade. The handler accepts a JSON body, hands the username and password fields directly to User.findOne, and treats a non-null result as a successful login:
// VULNERABLE
app.post('/login', async (req, res) => {
const user = await User.findOne({
username: req.body.username,
password: req.body.password,
});
if (user) {
req.session.userId = user._id;
return res.json({ ok: true });
}
return res.status(401).json({ ok: false });
}); A request body of {"username": {"$ne": null}, "password": {"$ne": null}} turns the filter into "any document whose username and password are both not null," which matches the first record in the collection and logs the attacker in as that user. The fix is to coerce inputs to the type the schema expects before they reach the query builder, validate the shape of the request body against an explicit schema, and never rely on the query builder itself to enforce types it was not designed to enforce:
// FIXED
import { z } from 'zod';
const LoginSchema = z.object({
username: z.string().min(1).max(64),
password: z.string().min(1).max(256),
});
app.post('/login', async (req, res) => {
const parsed = LoginSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ ok: false });
}
const { username, password } = parsed.data;
const user = await User.findOne({
username: String(username),
password: String(password),
});
if (user) {
req.session.userId = user._id;
return res.json({ ok: true });
}
return res.status(401).json({ ok: false });
}); Two defenses stack here, because either alone has historically failed under refactoring. The Zod schema rejects the request before it reaches the query when username arrives as an object, which closes the operator-injection primitive at the parsing layer. The explicit String() cast at the query site is the second line of defense for the next engineer who decides to skip the validator on a "trusted internal endpoint" that turns out to face the public internet. Production code should also never store a plaintext password field, but that is a separate failure that the next code review hopefully catches before this one does.
The Raw Query Passthrough
A more aggressive variant of the same bug ships in product-search and admin-list endpoints that forward the entire query string straight into MongoDB as a filter. The pattern looks productive — the developer gets a generic search endpoint with one line of code — and is wildly unsafe:
// VULNERABLE
app.get('/products', async (req, res) => {
const products = await db.collection('products').find(req.query).toArray();
return res.json(products);
}); A request to /products?price[$gt]=0&internal[$ne]=null returns every product, including draft and internal-only documents, because Express parses bracketed query strings into nested objects by default and the resulting filter contains attacker-controlled operators. The fix is to allowlist the fields the endpoint actually accepts, coerce each one to the type the schema expects, and refuse to forward any structure that the developer did not explicitly approve:
// FIXED
const ALLOWED_FIELDS = ['category', 'brand'];
app.get('/products', async (req, res) => {
const filter = { published: true };
for (const field of ALLOWED_FIELDS) {
const value = req.query[field];
if (typeof value === 'string' && value.length > 0 && value.length < 64) {
filter[field] = value;
}
}
const products = await db.collection('products')
.find(filter)
.limit(100)
.toArray();
return res.json(products);
}); The allowlist is the load-bearing defense. The typeof value === 'string' guard drops any field whose value arrives as an object, array, or operator expression, which closes the type-confusion primitive even if a future engineer adds a new field to the allowlist without thinking about NoSQL injection. The explicit { published: true } base filter makes it impossible to enumerate unpublished documents regardless of what else the request supplies. The limit(100) caps a denial-of-service variant where an attacker requests the entire collection at once.
The $where Operator and Server-Side JavaScript
MongoDB's $where operator accepts a JavaScript expression that the server evaluates against each document during a scan. The feature was useful in 2012, has been deprecated in stages since MongoDB 4.4, and remains enabled by default on a depressing number of self-hosted instances. The vulnerable shape concatenates user input into a $where string:
// VULNERABLE
app.get('/users/by-name', async (req, res) => {
const name = req.query.name;
const users = await db.collection('users').find({
$where: `this.name === '${name}'`,
}).toArray();
return res.json(users);
}); A request to /users/by-name?name=x'; sleep(5000); var z=' turns the $where body into this.name === 'x'; sleep(5000); var z='', which the MongoDB JavaScript engine dutifully executes for every document in the collection. The attacker has just achieved arbitrary JavaScript execution against the database server. The fix is to remove $where entirely — either by rewriting the query in terms of indexable operators like $eq, or by setting javascriptEnabled: false in the MongoDB server configuration so the operator throws on use:
// FIXED
app.get('/users/by-name', async (req, res) => {
const name = String(req.query.name || '').slice(0, 64);
if (!/^[a-zA-Z0-9 .'-]+$/.test(name)) {
return res.status(400).json({ ok: false });
}
const users = await db.collection('users')
.find({ name: { $eq: name } })
.limit(50)
.toArray();
return res.json(users);
}); The $eq rewrite is faster than $where at any collection size, uses the index on name if one exists, and does not invoke the JavaScript engine at all. The explicit string coercion and regex allowlist add a belt-and-suspenders defense for the case where a future engineer forgets why the endpoint was rewritten. On self-hosted MongoDB, set security.javascriptEnabled: false in mongod.conf so that the next developer who reaches for $where gets a configuration error instead of a working query.
Firebase Realtime Database and the Missing Rule
Firebase shifts the injection surface from the query layer to the security-rules layer. The Firebase docs themselves catalogue the same failure case in their best-practice guides: a developer wires up the Realtime Database with the default rules {"rules": {".read": true, ".write": true}}, ships the application, and discovers six months later that the entire user collection is reachable through any browser console with a few lines of firebase.database().ref('/users').once('value'). The fix is to replace the open default with rules that scope reads and writes to the authenticated principal:
// VULNERABLE rules
{
"rules": {
".read": true,
".write": true
}
}
// FIXED rules
{
"rules": {
"users": {
"$uid": {
".read": "$uid === auth.uid",
".write": "$uid === auth.uid && auth.token.email_verified === true"
}
}
}
} The $uid === auth.uid predicate restricts each user document to its owner. The auth.token.email_verified guard adds a second factor for write operations. Firebase rules are not a query language and therefore are not vulnerable to operator injection in the MongoDB sense, but they share the same root cause as the rest of the bug class: the default behaviour is permissive, and the developer has to know to override it.
Why NoSQL Injection Refuses to Die
Three structural reasons keep this bug class alive in 2026 despite a decade of advisories. First, JavaScript on both ends of the wire makes type confusion the path of least resistance. A request body parsed by express.json() arrives as a plain object whose keys could be strings or operators, and the developer who pulls req.body.username off that object has no syntactic signal that the value might not be a string. TypeScript helps when applied with strict input validation, but a typed handler that accepts any for the request body — which the default Express types still do — provides exactly zero protection against an object arriving where a string was expected.
Second, the assumption that "no SQL means no injection" was repeated often enough in the early MongoDB marketing era that it became received wisdom in onboarding documents and bootcamp curricula. New engineers entering the field after 2018 learned NoSQL as the default backend, learned that SQL injection was a solved class, and never learned that the same conceptual problem reappears in a different form when the query language is JSON. Third, the framework defaults are still permissive: Mongoose will gladly accept an operator object as a field value, Firebase rules default to open during development and frequently ship that way to production, and Express's qs parser turns bracketed query strings into nested objects without complaint. The path of least resistance leads through the bug class.
Detection: Where Each Layer Earns Its Keep
NoSQL injection has a fingerprint that static analysis catches because the dangerous pattern is a finite set of data-flow shapes: a request-body or query-string value flowing into findOne, find, updateOne, deleteMany, or any other Mongoose or driver query sink without an intervening type assertion. SAST traces the path from the source — req.body, req.query, req.params — across method boundaries to the sink and flags every case where no string coercion or schema validation happens in between; see why data flow analysis matters for the inter-procedural mechanics that catch a query built in one helper and executed in another.
DAST attacks the running application by replacing each string parameter with operator-injection payloads — {"$ne": null}, {"$gt": ""}, {"$regex": ".*"} — and watching for behavioural differences that indicate the operator was applied. Manual code review catches the type-confused queries that SAST flags as suspicious but cannot fully resolve, particularly on dynamically built filters where the field set varies at runtime. Firebase rules deserve their own review pass because the rules file is its own configuration language and the rest of the test suite never exercises it. The cheapest of the four layers is SAST because it runs on the diff that introduced the unsafe handler, before the build is reachable to a tester at all.
Prevention Checklist
Six rules close the overwhelming majority of real-world NoSQL injection. Apply them in order; each later rule assumes the earlier ones are in place.
- Cast inputs to the expected type before they reach the query. If the schema expects a string, write
String(value)at the query site. The cast turns an injected operator object into the literal string"[object Object]", which matches nothing. - Validate request shape with an explicit schema. Use Zod, Joi, Yup, or AJV to reject any request whose fields are not the type the endpoint expects. The validator runs before the handler logic and rejects operator-injection attempts at the parsing layer.
- Allowlist accepted fields per endpoint. Never forward
req.bodyorreq.querywholesale into a query. List the fields the endpoint accepts, copy each one explicitly, and drop everything else. - Disable
$whereand server-side JavaScript in MongoDB. Setsecurity.javascriptEnabled: falseinmongod.confso that the operator throws on use rather than evaluating attacker-controlled code. - Use an ORM or ODM with a strict schema. Mongoose with
strict: 'throw'and explicit field types catches some operator-injection patterns at model boundary, though it does not replace input validation. - Audit Firebase security rules before the first production deploy. Default rules are permissive; production rules should scope every read and write to the authenticated principal and reject all unauthenticated traffic.
Where GraphNode SAST Fits
GraphNode SAST traces user-controlled JSON and query-string values from request entry points into MongoDB, Mongoose, Couchbase, and Firebase query sinks, flagging the cases where no type coercion or schema validation sits between source and sink. Findings surface on the diff that introduced the unsafe handler. NoSQL injection is classified under A03 Injection because the bug shares its root with SQL injection — untrusted input flowing into a query — at a different syntactic layer. SAST catches the missed cast at the only point where the fix is still one line.
Closing
NoSQL injection has a documented fix that fits in a single function call. Despite that, it shipped in Rocket.Chat advisories across 2018-2022, in admin tools whose authors had read the SQL-injection cheat sheet but not the NoSQL one, and in the next Mongoose login handler near you. The pattern persists because JavaScript types do not enforce themselves, the framework defaults forward operator objects without complaint, and the seam where untyped JSON enters the application is rarely owned by anyone in particular. The teams that stop shipping NoSQL injection are the ones that move detection upstream into the diff, gate every query handler at code review, and treat input validation as the entry point of the application rather than a layer somebody else will add later. The fix is a string cast. The hard part is making sure it ships.
GraphNode SAST flags unsafe NoSQL query patterns across 13+ languages — request a demo.
Request Demo