NoSQL and ORM Injection

JSON APIs plus document databases create an injection class where the payload is an object, not a string.

NoSQL databases do not parse SQL, so people assume injection does not apply. It does — the payload is simply a different shape. In MongoDB the query is a JSON object, and a JSON API accepts JSON objects from the client.

The operator injection

A login handler does User.findOne({ email, password }). The client is supposed to send strings. It sends:

{ "email": "[email protected]", "password": { "$ne": null } }

The query becomes "password is not null", which matches, and the attacker is logged in as the admin. No quotes, no escaping, no SQL — just a type the code did not expect.

The root cause is type confusion

Everything here follows from accepting an object where a string was assumed. Validating the type — with a schema that requires a string — closes the entire class in one line.

The operators to know

  • $ne, $gt, $regex — authentication bypass and blind extraction, one character at a time.
  • $where — server-side JavaScript. Disable it at the database.
  • $expr, $function — the same problem in newer forms.

Query strings too

Express's default query parser expands ?filter[$ne]=1 into a nested object. A query parameter you assumed was a string arrives as { $ne: '1' }. Set query parser to simple, or validate types on query parameters exactly as you do on the body.

ORM injection

The same shape appears in SQL ORMs that accept object filters — Prisma, Sequelize, TypeORM. Passing req.body.where straight into a query lets the client author arbitrary conditions, including on relations. Never let a client supply a where object; build it from an allowlist.

Example

Example · javascript
// ❌ The classic authentication bypass
app.post('/login', async (req, res) => {
  const user = await User.findOne({
    email: req.body.email,
    password: req.body.password,
  });
  if (user) return res.json({ token: sign(user) });
});

// POST /login {"email":"[email protected]","password":{"$ne":null}}
// → the query means "password is not null" → logged in as admin.

// ✅ Require the type, and the whole class disappears
const loginSchema = z.object({
  email: z.string().email().max(200),
  password: z.string().min(1).max(200),      // ← a STRING. Not an object.
}).strict();

When to use it

  • An authentication bypass is fixed by requiring the password field to be a string, closing every operator-injection variant at once.
  • A search endpoint accepting a raw filter object let clients query across tenants until the filter was rebuilt from an allowlist.
  • A blind extraction attack using $regex is stopped by rejecting non-string types before the query is constructed.

More examples

MongoDB: the attacks and the fixes

select: false on the hash is worth copying — it means a forgotten .select() cannot leak credentials in a response.

Example · javascript
// ── 1. Authentication bypass ──────────────────────────────────────────
// {"email":"[email protected]","password":{"$ne":null}}
// {"email":{"$gt":""},"password":{"$gt":""}}      ← any user at all

// ── 2. Blind extraction, one character at a time ──────────────────────
// {"email":"[email protected]","password":{"$regex":"^a"}}   → 401
// {"email":"[email protected]","password":{"$regex":"^b"}}   → 200 ← first char
// A few hundred requests recover the whole value.

// ── 3. Server-side JavaScript ─────────────────────────────────────────
// {"$where":"sleep(5000) || true"}    ← DoS, and worse on older versions

// ── THE FIX: validate the type, then build the query yourself ─────────
import { z } from 'zod';

const loginSchema = z.object({
  email: z.string().email().max(200),
  password: z.string().min(1).max(200),
}).strict();

app.post('/login', validate({ body: loginSchema }), async (req, res) => {
  // Both are guaranteed strings now — no operator can survive.
  const user = await User.findOne({ email: req.body.email.toLowerCase() });

  // And NEVER compare passwords in the query. Fetch, then verify the hash.
  const ok = user && await argon2.verify(user.passwordHash, req.body.password);
  if (!ok) return res.status(401).json({ error: 'invalid_credentials' });

  res.json({ accessToken: issueToken(user) });
});

// ── Defence in depth ──────────────────────────────────────────────────
// 1. Strip operators from anything that reaches a query
import mongoSanitize from 'express-mongo-sanitize';
app.use(mongoSanitize({ replaceWith: '_' }));   // removes keys starting with $

// 2. Turn off server-side JS at the DATABASE (mongod.conf)
//    security:
//      javascriptEnabled: false

// 3. Fix the query parser — this is how ?x[$ne]=1 becomes an object
app.set('query parser', 'simple');   // 'extended' (default) expands brackets

// 4. Schemas at the model layer as well
const userSchema = new mongoose.Schema({
  email: { type: String, required: true, index: true },
  passwordHash: { type: String, required: true, select: false },  // never returned
}, { strict: 'throw' });             // reject unknown fields on write

ORM filter injection: the same bug in SQL

The explicit select is doing double duty here: it prevents ORM injection from widening the result set and prevents new columns leaking as the schema evolves.

Example · javascript
// ❌ Letting the client author the query. Convenient, and completely open.
app.get('/api/invoices', auth, async (req, res) => {
  const where = JSON.parse(req.query.filter ?? '{}');
  res.json(await prisma.invoice.findMany({ where }));
});

// ?filter={"userId":{"not":"me"}}                       → everyone's invoices
// ?filter={"user":{"email":{"contains":"@rival.com"}}}  → traverse relations
// ?filter={"OR":[{"id":{"gt":"0"}}]}                    → the whole table

// ✅ Build the query from an allowlist. The client picks from a menu.
const FILTERS = {
  status: (v) => ({ status: z.enum(['draft','open','paid','void']).parse(v) }),
  minTotal: (v) => ({ totalCents: { gte: z.coerce.number().int().min(0).parse(v) } }),
  customerId: (v) => ({ customerId: z.string().uuid().parse(v) }),
  createdAfter: (v) => ({ createdAt: { gte: z.coerce.date().parse(v) } }),
};

app.get('/api/invoices', auth, async (req, res) => {
  const where = { userId: req.user.id };        // authorization, always first

  for (const [key, value] of Object.entries(req.query)) {
    const build = FILTERS[key];
    if (!build) continue;
    Object.assign(where, build(value));         // throws on invalid input
  }

  const invoices = await prisma.invoice.findMany({
    where,
    // Explicit select: no accidental column exposure as the schema grows.
    select: { id: true, reference: true, totalCents: true, status: true,
              createdAt: true },
    take: Math.min(Number(req.query.limit) || 20, 100),
    orderBy: { createdAt: 'desc' },
  });

  res.json({ data: invoices });
});

// The same rule in every ORM:
//   Sequelize  — never pass req.body into `where`
//   TypeORM    — never into `find({ where })`
//   Prisma     — never into `findMany({ where })`
//   Mongoose   — never into `find()`
// The client sends NAMES of filters; the server decides what they mean.

Tests that pin the type confusion shut

Asserting 400 rather than 401 is the sharp part of these tests — a 401 would pass while the underlying type confusion remained.

Example · javascript
// Once fixed, these tests stop it coming back — and it does come back, because
// the vulnerable version reads more naturally.

const OPERATOR_PAYLOADS = [
  { $ne: null },
  { $ne: '' },
  { $gt: '' },
  { $regex: '.*' },
  { $exists: true },
  { $where: '1==1' },
  [{ $ne: null }],             // array wrapper
  { $in: ['a', 'b'] },
];

describe('NoSQL / ORM operator injection', () => {
  for (const payload of OPERATOR_PAYLOADS) {
    it(`login rejects a password of ${JSON.stringify(payload)}`, async () => {
      const res = await request(app).post('/login').send({
        email: '[email protected]',
        password: payload,
      });
      expect(res.status).toBe(400);                 // validation, not 401
      expect(res.body).not.toHaveProperty('accessToken');
    });

    it(`login rejects an email of ${JSON.stringify(payload)}`, async () => {
      const res = await request(app).post('/login').send({
        email: payload, password: 'whatever',
      });
      expect(res.status).toBe(400);
    });
  }

  it('query string bracket notation does not become an operator', async () => {
    // With the default 'extended' parser this arrives as { $ne: '1' }
    const res = await request(app)
      .get('/api/invoices?status[$ne]=paid')
      .set('Authorization', `Bearer ${aliceToken}`);

    expect([200, 400]).toContain(res.status);
    if (res.status === 200) {
      // If accepted, it must have been treated as a plain string and ignored
      expect(res.body.data.every((i) => i.userId === alice.id)).toBe(true);
    }
  });

  it('a client-supplied filter object cannot widen the result set', async () => {
    const res = await request(app)
      .get(`/api/invoices?filter=${encodeURIComponent('{"userId":{"not":"x"}}')}`)
      .set('Authorization', `Bearer ${aliceToken}`);
    const ids = (res.body.data ?? []).map((i) => i.id);
    const foreign = await db('invoices').whereIn('id', ids).whereNot({ user_id: alice.id });
    expect(foreign).toHaveLength(0);
  });
});

// Expecting 400 rather than 401 matters: a 401 means the object reached the
// query and simply did not match. Validation should have rejected it earlier.

Discussion

  • Be the first to comment on this lesson.