NoSQL Injection: MongoDB, Firebase, and DynamoDB Attack Patterns
Understand how NoSQL injection attacks work across MongoDB, Firebase, and DynamoDB, and learn the validation patterns that prevent them — including Mongoose input sanitization.
SQL injection has been OWASP's top web vulnerability for over a decade. NoSQL injection is its less-discussed cousin — different syntax, same root cause: unsanitized user input is interpreted as a query operator. The attack surface is real and actively exploited against MongoDB, Firebase, and DynamoDB.
MongoDB Operator Injection
MongoDB queries use JavaScript-like operator objects. When user input flows into a query object without validation, an attacker can inject operators like $gt, $where, $regex, and $ne to change the query logic.
The Classic MongoDB Injection
Consider a login endpoint:
// Vulnerable Node.js/Express login handler
app.post('/login', async (req, res) => {
const { username, password } = req.body;
const user = await User.findOne({
username: username,
password: password,
});
if (user) {
return res.json({ token: generateToken(user) });
}
res.status(401).json({ error: 'Invalid credentials' });
});
If the request body is:
{
"username": "admin",
"password": { "$gt": "" }
}
The query becomes:
User.findOne({
username: 'admin',
password: { $gt: '' }
})
$gt: "" matches any non-empty string. If an admin user exists, this query returns them and the attacker is authenticated without knowing the password.
The $where Injection
$where executes a JavaScript expression server-side against each document:
// Vulnerable search
const users = await User.find({
$where: `this.username === '${req.query.name}'`
});
Inject '; return true; var x=':
$where: "this.username === ''; return true; var x='"
// Returns all users
Or inject a denial-of-service:
$where: "'; while(true) {} var x='"
// Infinite loop on the MongoDB server
MongoDB has deprecated $where in recent versions, but older codebases still use it.
Prevention: Mongoose Input Validation
The correct approach is to validate and type-coerce input before it reaches any query:
import { z } from 'zod';
import User from '@/models/User';
const loginSchema = z.object({
username: z.string().min(1).max(64),
password: z.string().min(1).max(128),
});
async function loginUser(rawInput: unknown) {
// Validate: Zod will reject objects/arrays — only strings pass
const parsed = loginSchema.safeParse(rawInput);
if (!parsed.success) {
return { success: false, error: 'Invalid input' };
}
const { username, password } = parsed.data;
// Now username and password are guaranteed to be strings
const user = await User.findOne({ username, password: hashPassword(password) });
if (!user) {
return { success: false, error: 'Invalid credentials' };
}
return { success: true, data: user };
}
Never pass raw req.body directly into a Mongoose query. Always destructure the fields you need and validate their types.
For search endpoints that use regex:
const searchSchema = z.object({
q: z.string().max(100).regex(/^[a-zA-Z0-9\s\-_]+$/, 'Invalid characters'),
});
async function searchUsers(rawInput: unknown) {
const parsed = searchSchema.safeParse(rawInput);
if (!parsed.success) {
return { success: false, error: 'Invalid search query' };
}
// Escape special regex characters
const escaped = parsed.data.q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const users = await User.find({
username: { $regex: escaped, $options: 'i' },
}).limit(20);
return { success: true, data: users };
}
Mongoose Schema-Level Protection
Mongoose schemas provide some protection by casting values to the declared type. A String field will receive [object Object] if an object is passed, not the object itself. But this behavior varies by field type and Mongoose version — do not rely on it as your only defense.
import { Schema, model } from 'mongoose';
const userSchema = new Schema({
username: {
type: String,
required: true,
// trim and lowercase normalize input
trim: true,
lowercase: true,
// Reject any username that looks like an operator
validate: {
validator: (v: string) => !v.startsWith('$'),
message: 'Invalid username',
},
},
password: { type: String, required: true },
});
Sanitizing with express-mongo-sanitize
For Express applications, express-mongo-sanitize removes keys starting with $ from req.body, req.query, and req.params:
import express from 'express';
import mongoSanitize from 'express-mongo-sanitize';
const app = express();
app.use(express.json());
// Sanitize all incoming requests
app.use(mongoSanitize({
replaceWith: '_', // Replace $ with _ instead of removing
onSanitizeValue: (key, value) => {
console.warn(`Sanitized injection attempt: ${key}=${JSON.stringify(value)}`);
},
}));
This is a useful defense-in-depth layer, but it does not replace input validation. An attacker who controls the structure of the query object differently (e.g., via deeply nested parameters) may bypass sanitization.
Firebase Security Rules Bypass
Firebase Realtime Database and Firestore use security rules evaluated server-side to control access. Misconfigured rules are the primary attack vector against Firebase applications.
The Open Rules Vulnerability
The most common misconfiguration — and the one that results in data breaches most frequently:
// Realtime Database rules
{
"rules": {
".read": true,
".write": true
}
}
These rules allow anyone on the internet to read and write every document in the database. Firebase explicitly warns about this in its console, but many developers set it during development and forget to tighten it.
Firebase lists of exposed databases are regularly shared on hacker forums. Scanning for open Firebase instances is trivial:
# Attackers check if the database is open like this:
curl "https://your-project.firebaseio.com/.json"
# Returns all data if rules are open
Proper Firestore Rules
Always scope rules to the authenticated user's UID:
// firestore.rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Users can only read/write their own document
match /users/{userId} {
allow read, write: if request.auth != null
&& request.auth.uid == userId;
}
// Orders: users can read their own, admins can read all
match /orders/{orderId} {
allow read: if request.auth != null
&& (request.auth.uid == resource.data.userId
|| request.auth.token.admin == true);
allow write: if request.auth != null
&& request.auth.uid == resource.data.userId;
}
// Public read, authenticated write with validation
match /products/{productId} {
allow read: if true;
allow write: if request.auth != null
&& request.auth.token.admin == true
&& request.resource.data.price is number
&& request.resource.data.price > 0;
}
// Deny everything else
match /{document=**} {
allow read, write: if false;
}
}
}
Firebase Rules Injection via Client-Side Logic
A subtler attack: rules that validate field values based on data the client controls.
// Insecure rule — client can write any role
match /users/{userId} {
allow write: if request.auth.uid == userId
&& request.resource.data.keys().hasOnly(['name', 'email', 'role']);
}
This rule allows the client to set role: "admin". The correct approach validates allowed values:
match /users/{userId} {
allow write: if request.auth.uid == userId
&& request.resource.data.keys().hasOnly(['name', 'email'])
&& !('role' in request.resource.data);
// Role is set server-side only via Admin SDK
}
Test your Firebase rules with the Firebase Emulator Suite's rules unit tests:
// Test that a user cannot escalate their own role
it('denies role escalation', async () => {
const db = testEnv.authenticatedContext('alice').firestore();
await firebase.assertFails(
db.collection('users').doc('alice').set({
name: 'Alice',
role: 'admin', // Should fail
})
);
});
DynamoDB Expression Injection
DynamoDB uses a different expression syntax, but injection is still possible when user input ends up in expression strings.
The Vulnerable Pattern
// Vulnerable: user input concatenated into an expression string
async function getUserByFilter(filterField: string, filterValue: string) {
const params = {
TableName: 'Users',
FilterExpression: `${filterField} = :val`, // DANGEROUS
ExpressionAttributeValues: {
':val': filterValue,
},
};
return dynamodb.scan(params).promise();
}
// Attacker calls: getUserByFilter('1=1 OR userId', 'anything')
// FilterExpression becomes: "1=1 OR userId = :val" — returns all rows
The Safe Pattern
Never construct expression strings from user input. Use a fixed allowlist of filterable fields:
import { DynamoDBClient, ScanCommand } from '@aws-sdk/client-dynamodb';
import { z } from 'zod';
const client = new DynamoDBClient({ region: 'us-east-1' });
const ALLOWED_FILTER_FIELDS = ['status', 'tier'] as const;
const filterSchema = z.object({
field: z.enum(ALLOWED_FILTER_FIELDS),
value: z.string().min(1).max(64),
});
async function getUsersByFilter(rawInput: unknown) {
const parsed = filterSchema.safeParse(rawInput);
if (!parsed.success) {
return { success: false, error: 'Invalid filter' };
}
const { field, value } = parsed.data;
// field is guaranteed to be 'status' or 'tier' — never user-controlled
const command = new ScanCommand({
TableName: 'Users',
FilterExpression: '#field = :val',
ExpressionAttributeNames: {
'#field': field, // ExpressionAttributeNames prevents name injection
},
ExpressionAttributeValues: {
':val': { S: value },
},
});
const response = await client.send(command);
return { success: true, data: response.Items };
}
ExpressionAttributeNames is not just for reserved words — it also separates the field name from the expression string, preventing injection of DynamoDB expression syntax.
General NoSQL Injection Prevention Checklist
Across all NoSQL databases, apply these principles:
-
Validate type before use: Reject objects when you expect strings.
typeof input !== 'string'is a fast check before passing anything to a query. -
Use schema validation libraries: Zod, Joi, and Yup all reject unexpected types cleanly. Wire them at your API boundary before any database call.
-
Never interpolate user input into query strings: This applies to MongoDB
$where, DynamoDB expression strings, and Firebase rules that reference user-provided field names. -
Use parameterized query builders: Mongoose, AWS SDK v3's DynamoDB DocumentClient, and the Firebase Admin SDK all support parameterized-style queries. Use the SDK's own value interpolation, not string concatenation.
-
Principle of least query privilege: The query should only access the documents the user is allowed to see. Combine database-level ACLs (Firebase rules, MongoDB RBAC) with application-level validation.
-
Log unexpected type coercions: If your validation layer is stripping operator objects, log it. Repeated injection attempts are an early warning signal of active probing.
NoSQL injection is not new, but it remains common because developers who come from SQL backgrounds don't think of JSON objects as a query injection vector. The fix is always the same: validate input types at the boundary, use parameterized APIs, and never build query structures from raw user input.