iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

NoSQL Injection

NoSQL injection is real. MongoDB, CouchDB, and DynamoDB all accept rich query objects, and naively passing user input as a filter lets attackers inject operators like $ne, $regex, or $where. The fix is the same as SQLi: validate, coerce types, and never trust the shape of the input.

Mongo + Node defensive patterns

EXAMPLE
// SCENARIO — a login form against MongoDB. Defensive perspective.

import express from 'express';
import { MongoClient } from 'mongodb';
import { z } from 'zod';

const app = express();
app.use(express.json());

// ─── VULNERABLE — passes the whole body as a filter ───────────

app.post('/login-bad', async (req, res) => {
    const user = await db.users.findOne({
        username: req.body.username,
        password: req.body.password,
    });
    if (!user) return res.sendStatus(401);
    req.session.userId = user._id.toString();
    res.json({ ok: true });
});

// ❌ A request with JSON body { username: 'admin', password: { '$ne': null } }
//    becomes filter:  { username: 'admin', password: { $ne: null } }
//    -> matches the admin record regardless of password.
//
// ❌ { username: { '$regex': '.*' }, password: { '$ne': null } } enumerates accounts.
//
// The bug isn't in MongoDB. It's that the app handed unfiltered JSON to a query builder.

// ─── FIX 1 — Validate + coerce input shape ─────────────────────

const LoginSchema = z.object({
    username: z.string().min(3).max(64),
    password: z.string().min(6).max(256),
});

app.post('/login', async (req, res) => {
    const parsed = LoginSchema.safeParse(req.body);
    if (!parsed.success) return res.status(400).json({ error: parsed.error.issues });

    const { username, password } = parsed.data;
    const user = await db.users.findOne({ username });
    if (!user) return res.sendStatus(401);

    const ok = await verifyPassword(password, user.passwordHash);
    if (!ok) return res.sendStatus(401);

    req.session.userId = user._id.toString();
    res.json({ ok: true });
});

// • Zod (or any JSON-schema validator) refuses objects with operator keys
// • Comparison happens after fetching the row — hash compared in code, not in the query
// • Filter only includes scalar values

// ─── FIX 2 — Strip $ keys (defensive helper) ──────────────────

function stripMongoOperators(value) {
    if (Array.isArray(value)) return value.map(stripMongoOperators);
    if (value && typeof value === 'object') {
        const out = {};
        for (const [k, v] of Object.entries(value)) {
            if (k.startsWith('$') || k.includes('.')) continue;     // refuse operators and dotted paths
            out[k] = stripMongoOperators(v);
        }
        return out;
    }
    return value;
}

app.get('/products', async (req, res) => {
    const filter = stripMongoOperators(req.query);
    const rows = await db.products.find(filter).limit(50).toArray();
    res.json(rows);
});

// Use this as belt-and-braces; the primary defence is still validation.

// ─── FIX 3 — Express middleware to block operator injection ────

import mongoSanitize from 'express-mongo-sanitize';

app.use(mongoSanitize({
    replaceWith: '_',
    onSanitize: ({ req, key }) => console.warn('mongo operator stripped', { key, path: req.path, ip: req.ip }),
}));

// mongoSanitize walks req.body / req.query / req.params and removes keys starting with $ or containing dots.
// One line of middleware, broad coverage.

// ─── FIX 4 — Coerce types at the boundary ──────────────────────

const SearchSchema = z.object({
    q:     z.string().max(100),
    page:  z.coerce.number().int().nonnegative().max(1000).default(0),
    limit: z.coerce.number().int().min(1).max(100).default(20),
});

app.get('/search', async (req, res) => {
    const parsed = SearchSchema.safeParse(req.query);
    if (!parsed.success) return res.status(400).json({ error: parsed.error });
    const { q, page, limit } = parsed.data;
    const filter = q ? { title: { $regex: escapeRegex(q), $options: 'i' } } : {};
    const rows = await db.products.find(filter).skip(page * limit).limit(limit).toArray();
    res.json(rows);
});

// Escape regex special characters so user input can't expand the pattern
function escapeRegex(s) {
    return s.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');
}

// ─── FIX 5 — Avoid $where, $accumulator, and Map-Reduce ──────────

// $where runs JavaScript on the server. NEVER include user-controlled data inside a $where clause.
// MongoDB 5.0+ marks $where deprecated; turn it off entirely in your driver config when possible.

// ─── FIX 6 — Aggregation pipelines: validate stage by stage ────

const PipelineSchema = z.array(z.object({}).strict());
// Realistically: don't let users submit raw aggregation stages — expose specific endpoints with
// known stages. Building a generic 'send your own pipeline' API is a recipe for compromise.

// ─── FIX 7 — Least-privilege database user ─────────────────────

// Application connects with a user that has read+write on its OWN databases only.
// Even a successful injection can't TouchOther tenants' data or drop collections.

// ─── FIX 8 — Log + alert on operator-shaped input ──────────────

function looksLikeOperatorPayload(obj) {
    if (!obj || typeof obj !== 'object') return false;
    return Object.keys(obj).some((k) => k.startsWith('$') || k.includes('.'));
}

app.use((req, _res, next) => {
    if (looksLikeOperatorPayload(req.body) || looksLikeOperatorPayload(req.query)) {
        log.warn({ ip: req.ip, path: req.path }, 'possible NoSQL injection probe');
    }
    next();
});

// ─── REGRESSION TESTS ──────────────────────────────────────────

import request from 'supertest';

test('login rejects operator injection', async () => {
    const res = await request(app)
        .post('/login')
        .send({ username: 'admin', password: { '$ne': null } });
    expect(res.status).toBe(400);                       // schema rejects object password
});

test('search rejects regex-shape inputs in fields that expect strings', async () => {
    const res = await request(app)
        .get('/search')
        .query({ q: JSON.stringify({ $regex: '.*' }) });
    expect(res.status).toBe(200);                       // body treated as plain string
});

// ─── DATA STORE-SPECIFIC NOTES ─────────────────────────────────

// • MongoDB: $where, $accumulator, $function — JS evaluation; refuse user input
// • CouchDB:  Mango queries with operator keys; same family of issues
// • DynamoDB: PartiQL with concatenation is the same shape of bug as SQLi — bind parameters
// • Redis:  no operator injection per se, but Lua scripts with concatenated input have the same risks
// • ElasticSearch:  query DSL accepts JSON; same fix: validate input shape, allowlist operators

// ─── CHECKLIST ─────────────────────────────────────────────────
// 1. Validate input with a schema (Zod, Joi, Yup) — refuse unexpected shapes
// 2. Sanitise as defence-in-depth (express-mongo-sanitize)
// 3. Compare credentials in code, not in the query
// 4. Strict coercion for numeric/boolean fields (z.coerce.*)
// 5. Escape user input that becomes a regex; never use $where with user data
// 6. Least-privilege DB user
// 7. Audit logs for operator-shaped input
// 8. Regression tests cover operator-injection payloads

Why it matters

NoSQL injection looks different from SQLi but boils down to the same bug: trusting the shape of user input. Validate with a schema (Zod, Joi), strip operator-prefixed keys with express-mongo-sanitize, coerce numerics at the boundary, and never compare passwords in a query — fetch the row, then verify the hash in code.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
// MongoDB / Couch are not immune.
// VULNERABLE (req.body.email is { '$ne': null }):
db.users.findOne({ email: req.body.email })
// SAFE — coerce types or validate with Zod / Joi before querying:
db.users.findOne({ email: String(req.body.email) });
Try it Yourself »

Exercise

Coerce a Mongo query field to a string.

db.users.findOne({ email: (req.body.email) });

Discussion

Loading…