MongoDB / Mongoose Security Audit
Audit MongoDB usage (raw driver and Mongoose ODM) for NoSQL-specific vulnerabilities.
When this skill applies
- Reviewing Mongoose schemas and model usage
- Auditing raw MongoDB driver queries
- Checking for NoSQL operator injection
- Reviewing aggregation pipelines for safety
- Auditing tenant scoping across queries
Workflow
Follow ../_shared/audit-workflow.md. Companion: prisma-orm-security for IDOR/mass-assignment patterns generally.
Phase 1: Stack detection
grep -E '"(mongoose|mongodb|@mongodb)":' package.json
mongosh --version 2>/dev/null
Phase 2: Inventory
# Mongoose schemas
grep -rn 'new mongoose.Schema\|new Schema(' src/ | head
# Model queries
grep -rnE 'Model\.(find|findOne|findById|create|update|delete|aggregate)' src/ | head -30
# Operator-bearing queries (potential injection)
grep -rn '\$where\|\$ne\|\$gt\|\$lt\|\$regex' src/
# Aggregation pipelines
grep -rn '\.aggregate(' src/ | head
# Connection strings
grep -rn 'mongodb://\|mongodb+srv://' src/
Phase 3: Detection — the checks
NoSQL operator injection
The classic attack: user submits { "$gt": "" } for a password field; query matches any document.
// BAD — accepts arbitrary operator objects
app.post('/login', async (req, res) => {
const user = await User.findOne({ email: req.body.email, password: req.body.password });
// Attacker: { email: { $ne: "" }, password: { $ne: "" } } — finds any user
});
// GOOD — cast to expected primitive
const email = String(req.body.email);
const password = String(req.body.password);
const user = await User.findOne({ email, password: hashPassword(password) });
- MNG-INJ-1 Inputs cast to expected primitives (
String(x), Number(x)) before being used in queries.
- MNG-INJ-2 Or use a validator (Zod, Joi) that enforces primitive types — rejects objects.
- MNG-INJ-3
express-mongo-sanitize or equivalent middleware applied to strip $-prefixed keys from request bodies — defense in depth.
$where and JavaScript injection
$where runs JavaScript on the server. Never use with user input:
// CRITICAL — JS injection on the server
db.collection.find({ $where: `this.name == '${userInput}'` });
// CRITICAL too — $where with function from user
db.collection.find({ $where: req.body.predicate });
- MNG-WHERE-1 No
$where in production code, or only with hardcoded strings.
- MNG-WHERE-2
$expr with $function (MongoDB 4.4+) similarly dangerous; allowlist if used.
$regex denial of service
// User input as regex without sanitization → ReDoS
collection.find({ name: { $regex: req.query.search } });
- MNG-REGEX-1 User-provided regex strings escaped first (escape
.\+*?[](){}^$| etc.) OR matched via text indexes ($text) instead.
- MNG-REGEX-2 Anchored search with limit:
{ name: { $regex: ^${escape(input)}, $options: 'i' } }.
Mass assignment (Mongoose)
// BAD
const user = await User.create({ ...req.body });
// Attacker includes: { role: 'admin', isVerified: true }
- MNG-MA-1 Don't spread
req.body into Model.create / Model.findOneAndUpdate. Pick fields explicitly.
- MNG-MA-2 Mongoose schema has
strict: true (default) — extra fields rejected. But fields DECLARED on the schema can still be set if you spread. The schema doesn't auto-filter "admin only" fields.
- MNG-MA-3 For updates, use
$set: { specificField: value } rather than Model.updateOne({ id }, req.body).
// GOOD
const { name, bio } = CreateUserSchema.parse(req.body);
const user = await User.create({
name,
bio,
role: 'user', // server-controlled
tenantId: req.user.tenantId, // session-derived
});
IDOR — missing tenant scope
Sensitive fields in responses
- MNG-RES-1 Schema fields like
password, mfaSecret, apiKeyHash have select: false so they're excluded by default.
- MNG-RES-2 Or use explicit projection in queries:
User.findOne({...}, 'name email').lean().
- MNG-RES-3
toJSON transform configured to strip internal fields:schema.set('toJSON', {
transform: (doc, ret) => {
delete ret.password;
delete ret.__v;
return ret;
},
});
Aggregation pipeline safety
- MNG-AGG-1 User input embedded in pipeline stages parameterized via
$ references or pre-validated. Don't construct pipeline objects from raw request data.
- MNG-AGG-2
$lookup / $graphLookup stages preserve tenant scope in matching documents.
- MNG-AGG-3
allowDiskUse: false by default in production (limits resource use); enable selectively for known-large pipelines.
Connection strings
- MNG-CONN-1 Connection string from env, never hardcoded.
- MNG-CONN-2 SRV connection string with TLS in production (
mongodb+srv://...).
- MNG-CONN-3 Connection string doesn't have superuser credentials; use a role with minimum needed privileges.
- MNG-CONN-4 Connection pool size capped.
MongoDB authentication and roles
- MNG-DB-1 No
--noauth in production. Authentication enabled.
- MNG-DB-2 Roles: app user has
readWrite on the specific database, not root / dbAdmin.
- MNG-DB-3 Network: MongoDB not bound to
0.0.0.0 without firewall; Atlas IP allowlist configured.
- MNG-DB-4 TLS required.
Atlas-specific
- MNG-ATL-1 Network access list specific (not
0.0.0.0/0).
- MNG-ATL-2 Database user separate from Atlas org user.
- MNG-ATL-3 Atlas Audit Logs enabled.
- MNG-ATL-4 Encryption-at-rest using customer-managed keys for sensitive datasets.
Soft delete and tombstones
- MNG-SD-1 Soft-delete flag (
deletedAt) queries don't return tombstones to clients.
- MNG-SD-2 Indexes on tenant + deletedAt for performance.
Indexes
- MNG-IDX-1 Indexes on filter columns used in WHERE — unindexed queries on large collections enable DoS.
- MNG-IDX-2 Unique indexes on identifiers (email, slug).
- MNG-IDX-3 Compound indexes include tenant_id first for multi-tenant collections.
Logging
- MNG-LOG-1 Profiling logs (slow query log) don't leak query contents with PII.
- MNG-LOG-2 Sensitive collections not logged at query level.
Dependencies
- MNG-DEP-1 Mongoose and MongoDB driver current. Old
mongodb < 4.x had bugs in BSON parsing.
- MNG-DEP-2
express-mongo-sanitize or alternative sanitizer present if input ever flows into queries.
Phase 4: Triage
Critical: login endpoint accepting object inputs (operator injection); $where with user input; queries without tenant scope; password field returned in responses.
Phase 5: Report
Use ../_shared/findings-schema.md. Prefix IDs with MNG-.
1---2name: mongoose-mongodb-security3description: Security audit for MongoDB and Mongoose-based applications including NoSQL operator injection ($where, $ne, $gt), mass assignment via spreading into Model.create, schema validation bypass, aggregation pipeline safety, lean() vs hydrated query exposure, missing tenant scoping, and MongoDB connection string handling. Use this skill whenever the user mentions MongoDB, Mongoose, mongoose.Schema, Model.create, Model.findOne, aggregate pipeline, $where, $regex, MongoClient, or asks "audit my MongoDB queries", "Mongoose security", "NoSQL injection". Trigger when the codebase contains `mongoose`, `mongodb`, or `@mongodb/*` in package.json.4---56# MongoDB / Mongoose Security Audit78Audit MongoDB usage (raw driver and Mongoose ODM) for NoSQL-specific vulnerabilities.910## When this skill applies1112- Reviewing Mongoose schemas and model usage13- Auditing raw MongoDB driver queries14- Checking for NoSQL operator injection15- Reviewing aggregation pipelines for safety16- Auditing tenant scoping across queries1718## Workflow1920Follow `../_shared/audit-workflow.md`. Companion: `prisma-orm-security` for IDOR/mass-assignment patterns generally.2122### Phase 1: Stack detection2324```bash25grep -E '"(mongoose|mongodb|@mongodb)":' package.json26mongosh --version 2>/dev/null27```2829### Phase 2: Inventory3031```bash32# Mongoose schemas33grep -rn 'new mongoose.Schema\|new Schema(' src/ | head3435# Model queries36grep -rnE 'Model\.(find|findOne|findById|create|update|delete|aggregate)' src/ | head -303738# Operator-bearing queries (potential injection)39grep -rn '\$where\|\$ne\|\$gt\|\$lt\|\$regex' src/4041# Aggregation pipelines42grep -rn '\.aggregate(' src/ | head4344# Connection strings45grep -rn 'mongodb://\|mongodb+srv://' src/46```4748### Phase 3: Detection — the checks4950#### NoSQL operator injection5152The classic attack: user submits `{ "$gt": "" }` for a password field; query matches any document.5354```js55// BAD — accepts arbitrary operator objects56app.post('/login', async (req, res) => {57 const user = await User.findOne({ email: req.body.email, password: req.body.password });58 // Attacker: { email: { $ne: "" }, password: { $ne: "" } } — finds any user59});6061// GOOD — cast to expected primitive62const email = String(req.body.email);63const password = String(req.body.password);64const user = await User.findOne({ email, password: hashPassword(password) });65```6667- **MNG-INJ-1** Inputs cast to expected primitives (`String(x)`, `Number(x)`) before being used in queries.68- **MNG-INJ-2** Or use a validator (Zod, Joi) that enforces primitive types — rejects objects.69- **MNG-INJ-3** `express-mongo-sanitize` or equivalent middleware applied to strip `$`-prefixed keys from request bodies — defense in depth.7071#### `$where` and JavaScript injection7273`$where` runs JavaScript on the server. Never use with user input:7475```js76// CRITICAL — JS injection on the server77db.collection.find({ $where: `this.name == '${userInput}'` });7879// CRITICAL too — $where with function from user80db.collection.find({ $where: req.body.predicate });81```8283- **MNG-WHERE-1** No `$where` in production code, or only with hardcoded strings.84- **MNG-WHERE-2** `$expr` with `$function` (MongoDB 4.4+) similarly dangerous; allowlist if used.8586#### `$regex` denial of service8788```js89// User input as regex without sanitization → ReDoS90collection.find({ name: { $regex: req.query.search } });91```9293- **MNG-REGEX-1** User-provided regex strings escaped first (escape `.\+*?[](){}^$|` etc.) OR matched via text indexes (`$text`) instead.94- **MNG-REGEX-2** Anchored search with limit: `{ name: { $regex: `^${escape(input)}`, $options: 'i' } }`.9596#### Mass assignment (Mongoose)9798```js99// BAD100const user = await User.create({ ...req.body });101// Attacker includes: { role: 'admin', isVerified: true }102```103104- **MNG-MA-1** Don't spread `req.body` into `Model.create` / `Model.findOneAndUpdate`. Pick fields explicitly.105- **MNG-MA-2** Mongoose schema has `strict: true` (default) — extra fields rejected. But fields DECLARED on the schema can still be set if you spread. The schema doesn't auto-filter "admin only" fields.106- **MNG-MA-3** For updates, use `$set: { specificField: value }` rather than `Model.updateOne({ id }, req.body)`.107108```js109// GOOD110const { name, bio } = CreateUserSchema.parse(req.body);111const user = await User.create({112 name,113 bio,114 role: 'user', // server-controlled115 tenantId: req.user.tenantId, // session-derived116});117```118119#### IDOR — missing tenant scope120121- **MNG-IDOR-1** Every `findById`, `findOne`, `updateOne`, `deleteOne` includes a tenant or owner filter.122 ```js123 // BAD124 const doc = await Doc.findById(req.params.id);125 126 // GOOD127 const doc = await Doc.findOne({ _id: req.params.id, tenantId: req.user.tenantId });128 ```129- **MNG-IDOR-2** Custom static methods on models that fetch documents enforce scoping at the method level.130131#### Sensitive fields in responses132133- **MNG-RES-1** Schema fields like `password`, `mfaSecret`, `apiKeyHash` have `select: false` so they're excluded by default.134- **MNG-RES-2** Or use explicit projection in queries: `User.findOne({...}, 'name email').lean()`.135- **MNG-RES-3** `toJSON` transform configured to strip internal fields:136 ```js137 schema.set('toJSON', {138 transform: (doc, ret) => {139 delete ret.password;140 delete ret.__v;141 return ret;142 },143 });144 ```145146#### Aggregation pipeline safety147148- **MNG-AGG-1** User input embedded in pipeline stages parameterized via `$` references or pre-validated. Don't construct pipeline objects from raw request data.149- **MNG-AGG-2** `$lookup` / `$graphLookup` stages preserve tenant scope in matching documents.150- **MNG-AGG-3** `allowDiskUse: false` by default in production (limits resource use); enable selectively for known-large pipelines.151152#### Connection strings153154- **MNG-CONN-1** Connection string from env, never hardcoded.155- **MNG-CONN-2** SRV connection string with TLS in production (`mongodb+srv://...`).156- **MNG-CONN-3** Connection string doesn't have superuser credentials; use a role with minimum needed privileges.157- **MNG-CONN-4** Connection pool size capped.158159#### MongoDB authentication and roles160161- **MNG-DB-1** No `--noauth` in production. Authentication enabled.162- **MNG-DB-2** Roles: app user has `readWrite` on the specific database, not `root` / `dbAdmin`.163- **MNG-DB-3** Network: MongoDB not bound to `0.0.0.0` without firewall; Atlas IP allowlist configured.164- **MNG-DB-4** TLS required.165166#### Atlas-specific167168- **MNG-ATL-1** Network access list specific (not `0.0.0.0/0`).169- **MNG-ATL-2** Database user separate from Atlas org user.170- **MNG-ATL-3** Atlas Audit Logs enabled.171- **MNG-ATL-4** Encryption-at-rest using customer-managed keys for sensitive datasets.172173#### Soft delete and tombstones174175- **MNG-SD-1** Soft-delete flag (`deletedAt`) queries don't return tombstones to clients.176- **MNG-SD-2** Indexes on tenant + deletedAt for performance.177178#### Indexes179180- **MNG-IDX-1** Indexes on filter columns used in WHERE — unindexed queries on large collections enable DoS.181- **MNG-IDX-2** Unique indexes on identifiers (email, slug).182- **MNG-IDX-3** Compound indexes include tenant_id first for multi-tenant collections.183184#### Logging185186- **MNG-LOG-1** Profiling logs (slow query log) don't leak query contents with PII.187- **MNG-LOG-2** Sensitive collections not logged at query level.188189#### Dependencies190191- **MNG-DEP-1** Mongoose and MongoDB driver current. Old `mongodb < 4.x` had bugs in BSON parsing.192- **MNG-DEP-2** `express-mongo-sanitize` or alternative sanitizer present if input ever flows into queries.193194### Phase 4: Triage195196Critical: login endpoint accepting object inputs (operator injection); `$where` with user input; queries without tenant scope; password field returned in responses.197198### Phase 5: Report199200Use `../_shared/findings-schema.md`. Prefix IDs with `MNG-`.