Skip to content

About

nosql-sanitize is a lightweight and blazing-fast suite of tools designed to prevent NoSQL Injection attacks in Node.js applications. It recursively sanitizes user-supplied data to remove characters and patterns (like $ and control characters) used in malicious MongoDB queries.

Topics

Resources

Contributing

Security policy

Stars

7 stars

Watchers

1 watching

Forks

Repository files navigation

nosql-sanitize logo

express version fastify version

nosql-sanitize is a lightweight and blazing-fast suite of tools designed to prevent NoSQL Injection attacks in Node.js applications. It recursively sanitizes user-supplied data to remove characters and patterns (like $ and control characters) used in malicious MongoDB queries.

Upgrading from v3? v4 switches to fail-closed defaults. See the migration guide.

Key Features

  • Blazing Fast: Optimized core engine with pre-compiled regex and minimal overhead.
  • Recursive Sanitization: Automatically cleans nested objects and arrays.
  • Framework Support: First-class support for Express (4.x/5.x) and Fastify (4.x/5.x).
  • Prototype Pollution Protection: Blocks __proto__, constructor, and prototype keys by default.
  • Configurable Depth Limiting: Control what happens to values beyond maxDepth — preserve, remove, or throw.
  • Highly Configurable: Control depth, allowed/denied keys, content-types, and more.
  • TypeScript Ready: Built-in, high-quality type definitions.
  • Security First: Preserves sensitive data like email addresses while stripping injection vectors.

📑 Contents

📦 Packages

Package Purpose Installation
@exortek/express-mongo-sanitize Express Middleware npm i @exortek/express-mongo-sanitize
@exortek/fastify-mongo-sanitize Fastify Plugin npm i @exortek/fastify-mongo-sanitize
@exortek/nosql-sanitize-core Core Engine npm i @exortek/nosql-sanitize-core

✅ Compatibility

Every framework major below is exercised by the integration test suite (yarn test:servers), which boots a real server on each and asserts identical sanitization behaviour.

Runtime Supported Verified in CI
Express 4.x and 5.x ✅ Express 4 + Express 5 servers
Fastify 4.x and 5.x ✅ Fastify 4 + Fastify 5 servers
Node.js >= 20 ✅
Modules CommonJS + ESM require() and import both work
TypeScript Bundled .d.ts No @types/* needed

🤔 Why?

In MongoDB, operators like $gt, $ne, and $where can be injected via JSON input to bypass logic or extract data:

// ❌ Malicious login attempt
{ "username": "admin", "password": { "$ne": "" } }

Without sanitization, this query might return the first user in the database (usually the admin) because password is "not equal to empty string". nosql-sanitize transforms this into:

// ✅ Sanitized input
{ "username": "admin", "password": { "ne": "" } }

⚡ Quick Start

Express

const express = require('express');
const mongoSanitize = require('@exortek/express-mongo-sanitize');

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

app.post('/login', (req, res) => {
  // req.body is already sanitized
  res.send('Safe!');
});

Fastify

const fastify = require('fastify')();
const mongoSanitize = require('@exortek/fastify-mongo-sanitize');

fastify.register(mongoSanitize);

fastify.post('/login', async (request) => {
  // request.body is already sanitized
  return { status: 'Safe!' };
});

⚙️ Configuration Options

All packages (express, fastify) accept the same configuration options, which are passed to the core engine.

Option Type Default Description
replaceWith string '' String to replace matched patterns (like $) with.
removeMatches boolean false If true, removes the entire key-value pair if a match is found.
sanitizeObjects string[] ['body', 'query'] Fields on the request object to sanitize.
contentTypes string[] | null ['application/json', 'application/x-www-form-urlencoded', 'multipart/form-data'] Only sanitize body for these content types (+json types follow application/json). null = all.
skipRoutes (string | RegExp)[] [] Routes to ignore during auto-sanitization.
recursive boolean true Whether to recursively sanitize nested objects/arrays.
maxDepth number | null 32 Maximum recursion depth. 0 = top-level strings only. null = no limit (throws max_depth past 256 levels).
maxDepthBehavior string 'remove' Action when maxDepth exceeded. See Depth Limiting.
preserveEmails boolean true Preserve email-looking values. See Email Preservation.
allowPrototypeKeys boolean false Allow __proto__ / constructor / prototype keys.
allowedKeys string[] [] Key whitelist, applied at every depth. See Allowing / Denying Specific Keys.
deniedKeys string[] [] Keys to completely remove from the input, whatever their value.
allowedOperators string[] [] $-operators kept verbatim (e.g. ['$set']). See Allowing / Denying Specific Keys.
dotKeys string 'replace' Keys containing .: 'allow', 'replace' (with replaceWith) or 'remove'.
removeEmpty boolean | 'falsy' | 'empty' false true / 'empty': remove keys whose value is '', null, undefined, {} or [] after sanitization (0 / false are kept). 'falsy': remove every falsy value.
patterns RegExp[] [/\$/g, /[\x00-\x1F\x7F-\x9F]/g] Non-empty array of RegExps (flags are merged). Override to add custom patterns.
customSanitizer function | null null Replace the built-in sanitizer with your own function.
mode 'auto' | 'manual' 'auto' 'auto' sanitizes on every request. 'manual' exposes req.sanitize().
onSanitize function | null null Audit callback. See onSanitize Events.
onInjection string 'sanitize' 'sanitize', 'reject' (400) or 'report' (dry run). See Reject or Report.
debug.enabled boolean false Enable detailed logging for debugging.

String Options

Fine-grained control over how string values are sanitized:

app.use(mongoSanitize({
  stringOptions: {
    trim: true,          // Trim whitespace from string values (keys are left alone)
    lowercase: true,     // Convert string values to lowercase (keys are left alone)
    maxLength: 500,      // Truncate values longer than 500 characters (keys are not truncated)
  }
}));

Array Options

Control how arrays are post-processed after sanitization:

app.use(mongoSanitize({
  arrayOptions: {
    filterNull: true,    // Remove null and undefined from arrays (same as 'nullish');
                         // use 'falsy' to also remove 0, '' and false
    distinct: true,      // Remove duplicate values from arrays
  }
}));

onSanitize Events

The onSanitize callback fires for every value change, key rename, and removal during sanitization. It provides full visibility into what was changed and why:

app.use(mongoSanitize({
  onSanitize: (event) => {
    console.log(`[${event.type}] ${event.path}: ${event.key} → ${event.sanitizedKey}`);
  }
}));

Event shape:

Field Description
type 'value', 'key', 'both', or 'removed'
reason Only for 'removed': 'deniedKey', 'notAllowed', 'prototypePollution', 'dotKey', 'removeMatches', 'removeEmpty', 'maxDepth', 'circular'
key Original key (e.g., '$gt')
sanitizedKey Key after sanitization (e.g., 'gt')
path Full dotted path, built from sanitized keys (e.g., 'body.user.gt' for { user: { $gt } })
originalValue Value before sanitization
sanitizedValue Value after sanitization (undefined for removals)

Note: onSanitize adds overhead. If you don't need audit logging, leave it null for maximum performance.

🔒 Security

Prototype Pollution Protection

By default, __proto__, constructor, and prototype keys are stripped from all objects. This prevents attacks like:

{ "__proto__": { "isAdmin": true } }

Smuggled variants are also caught — for example, a key containing hidden control characters can collapse to __proto__ after sanitization and is then blocked.

To opt out (only if you really know what you're doing):

app.use(mongoSanitize({ allowPrototypeKeys: true }));

Depth Limiting

The maxDepth option limits how deep the sanitizer recurses into nested objects. This is important for two reasons:

  1. Performance: Deeply nested payloads can cause excessive CPU usage.
  2. Security: Attackers can craft deeply nested objects to slow down your server (DoS).

The maxDepthBehavior option controls what happens when a value exceeds the depth limit:

Behavior Description Risk
'preserve' Pass through the over-depth value unsanitized Unsafe — injection vectors in deep nesting bypass sanitization. The v3 default.
'remove' (default) Drop the over-depth value entirely Safe — fail-closed. Over-depth data is discarded.
'throw' Throw a NoSQLSanitizeError Safe — rejects the entire request. Use with error handling middleware.

The defaults are maxDepth: 32 and maxDepthBehavior: 'remove': data nested deeper than 32 levels is dropped. Lower the limit if your payloads are shallow, or reject such requests outright:

app.use(mongoSanitize({
  maxDepth: 8,
  maxDepthBehavior: 'throw',
}));

Warning: maxDepthBehavior: 'preserve' is not secure. An attacker can place injection operators deeper than maxDepth and they will pass through unsanitized.

Setting maxDepth: 0 is valid and means "only sanitize top-level string values" — nested objects/arrays are handled per maxDepthBehavior.

Email Preservation

By default, values that look like email addresses (e.g., user@example.com) are passed through without sanitization. This prevents the @ or other characters from being accidentally stripped.

Important: When preserveEmails: true, the email bypass also skips any custom patterns you provide. If you add custom patterns that must apply to email values, set preserveEmails: false.

It only applies to values. deniedKeys always win, and email-looking keys still get their dots replaced (see dotKeys).

// Custom patterns that must also apply to emails
app.use(mongoSanitize({
  preserveEmails: false,
  patterns: [/\$/g, /@evil\.com/g],
}));

Content-Type Guard

By default, application/json, application/x-www-form-urlencoded and multipart/form-data request bodies are sanitized. Any +json type (application/vnd.api+json, application/merge-patch+json, …) is treated as JSON whenever application/json is in the list. The query object is always sanitized.

Multipart is included because body parsers like multer/busboy turn a field such as user[$ne]=x into { user: { $ne: 'x' } }, so a multipart route is just as injectable as a JSON one. Only the parsed plain-object fields are touched, never file streams or buffers. Circular references (for example @fastify/multipart field objects, whose fields property points back at the body) are cut and reported via onSanitize with reason 'circular'.

Bodies with any other content type pass through unsanitized. If you parse other types, add them to contentTypes.

// Add GraphQL bodies (keep the defaults you still need)
app.use(mongoSanitize({
  contentTypes: [
    'application/json',
    'application/x-www-form-urlencoded',
    'multipart/form-data',
    'application/graphql',
  ],
}));

// Sanitize all content types (use with caution)
app.use(mongoSanitize({ contentTypes: null }));

Production Hardening

The defaults are fail-closed: over-depth data is dropped (maxDepth: 32, maxDepthBehavior: 'remove') and dotted keys are neutralized (dotKeys: 'replace'). On top of that, you can:

app.use(mongoSanitize({
  maxDepth: 8,                // bound recursion to what your payloads need…
  maxDepthBehavior: 'throw',  // …and reject anything deeper
  dotKeys: 'remove',          // drop keys like 'profile.isAdmin' instead of renaming them
}));
  • Validate schemas as well. This library strips operator characters; it does not check types. { "age": { "gt": 1 } } survives sanitization as an object, so cast or validate (Joi, Zod, Fastify JSON schema, Mongoose casting, sanitizeFilter) before querying.
  • Watch dotted keys. By default, keys like profile.isAdmin become profileisAdmin (dotKeys: 'replace'). Still, never spread request bodies straight into $set.
  • Anchor skipRoutes regexes (/^\/health$/, not /health/). Otherwise they match anywhere in the path.

Custom Patterns

The default patterns strip $ (MongoDB operator prefix) and control characters. You can override these with your own:

app.use(mongoSanitize({
  patterns: [
    /\$/g,                          // MongoDB operator prefix
    /[\x00-\x1F\x7F-\x9F]/g,        // Control characters
    /\{.*\}/g,                      // Curly brace expressions
  ]
}));

Express 5 Compatibility

Express 5 defines req.query as a non-writable getter, which means req.query = sanitized throws a TypeError. This library detects non-writable properties and applies safe assignment strategies, so it works across Express 4.x and 5.x.

// No extra configuration needed — works with Express 4.x and 5.x
app.use(mongoSanitize());

Fastify Null-Proto Query Objects

Fastify parses query strings into objects with a null prototype. The sanitizer handles these correctly, so Fastify query objects are sanitized just like regular plain objects.

🛠 Advanced Usage

Reject or Report Instead of Sanitizing

By default an injection is stripped and the request continues. onInjection changes that:

// Answer 400 instead of silently cleaning the payload
app.use(mongoSanitize({ onInjection: 'reject' }));

// Dry run for a safe rollout: log what would change, leave requests untouched
app.use(mongoSanitize({
  onInjection: 'report',
  onSanitize: (event) => logger.warn({ event }, 'nosql-sanitize would change this'),
}));

'reject' throws NoSQLSanitizeError with type: 'injection_detected' and statusCode: 400, which Express and Fastify turn into a 400 response (customize it in your error handler). It uses the same rules as hasInjection. Requests without an injection are still sanitized as configured. 'report' requires onSanitize.

Debug Logging

Debug output goes to console.log (Express) or request.log (Fastify) unless you pass a logger. Any object with error, warn, info, debug and trace methods works (pino, fastify.log, console). It is called pino-style: logger.debug({ module: 'nosql-sanitize', context, data }, message).

const pino = require('pino');

app.use(mongoSanitize({
  debug: { enabled: true, level: 'debug', logger: pino({ level: 'debug' }) },
}));

// Fastify logs through request.log by default, so each line carries the request id
fastify.register(mongoSanitize, { debug: { enabled: true, level: 'debug' } });

debug.level filters first, then the logger's own level applies, so set both. Sanitized values are still only logged with logSanitizedValues: true.

Manual Mode

If you need fine-grained control over when sanitization occurs:

// Express
app.use(mongoSanitize({ mode: 'manual' }));
app.post('/sensitive', (req, res) => {
  req.sanitize();                // Trigger sanitization manually
  req.sanitize({ maxDepth: 2 }); // Or with custom options
  res.json(req.body);
});

// Fastify
fastify.register(mongoSanitize, { mode: 'manual' });
fastify.post('/sensitive', async (request) => {
  request.sanitize();
  return request.body;
});

Route Parameter Sanitization (Express only)

Route parameters (req.params) are not sanitized by default. Use the paramSanitizeHandler to sanitize specific parameters:

const { paramSanitizeHandler } = require('@exortek/express-mongo-sanitize');

app.param('username', paramSanitizeHandler());

app.get('/user/:username', (req, res) => {
  // GET /user/$admin → req.params.username is "admin"
  res.json({ username: req.params.username });
});

Adding 'params' to sanitizeObjects has no effect in Express. App-level middleware runs before routing, so req.params is still empty at that point.

Skipping Specific Routes

app.use(mongoSanitize({
  skipRoutes: [
    '/api/v1/webhook',        // Exact match (O(1) Set lookup)
    /^\/public\/.*/,          // Regex match
    /^\/api\/v[0-9]+\/health/ // Regex with version pattern
  ]
}));

Regex entries are tested against the whole cleaned path and are not anchored automatically, so /health/ would also skip /api/users/health. Always anchor with ^ (and $ where appropriate).

Custom Sanitizer

Replace the built-in sanitizer entirely:

app.use(mongoSanitize({
  customSanitizer: (data, options) => {
    // data is a shallow clone of the request field (body/query/params)
    // options is the resolved configuration
    // Return the sanitized data
    return myCustomSanitize(data);
  }
}));

Allowing / Denying Specific Keys

allowedKeys is a whitelist. When it's non-empty, any key not listed is removed at every depth of the payload. Listed keys are still sanitized like any other key, so $ is stripped from them. It does not let MongoDB operators through; use allowedOperators for that.

allowedOperators lists exact $-operators that are kept verbatim at every depth. They also pass the allowedKeys whitelist and are never dropped by removeMatches. Everything under them is still sanitized, and deniedKeys still removes them. Only use it on trusted routes (for example an admin update API), and never list operators that run JavaScript ($where, $function, $accumulator).

// Only these keys survive, at any nesting level
app.use(mongoSanitize({
  allowedKeys: ['name', 'email', 'address', 'city']
}));

// Deny specific keys (removed from input entirely)
app.use(mongoSanitize({
  deniedKeys: ['password_hash', 'internal_id']
}));

// Keep $set, sanitize everything inside it
app.use('/admin', mongoSanitize({
  allowedOperators: ['$set']
}));
// { "$set": { "name": "$x" }, "$inc": { "n": 1 } } -> { "$set": { "name": "x" }, "inc": { "n": 1 } }

Custom Replacement

Instead of removing $, replace it with an underscore:

app.use(mongoSanitize({
  replaceWith: '_'
}));
// { "$gt": 5 } -> { "_gt": 5 }

Outside HTTP (GraphQL, WebSockets, queues)

The core package exposes the sanitizer directly:

const { sanitize, hasInjection } = require('@exortek/nosql-sanitize-core');

const options = { maxDepth: 16, maxDepthBehavior: 'remove' }; // resolved once, reused

socket.on('message', (raw) => {
  const msg = JSON.parse(raw);
  if (hasInjection(msg, options)) return socket.close(1008); // reject…
  handle(sanitize(msg, options));                            // …or clean it
});

hasInjection doesn't modify anything. See the Core README for exactly what it reports.

⚡ Performance

Benchmarked on Apple M4 Pro (14 cores), Node.js v24.x with --expose-gc:

Request Pipeline (Full Sanitization)

Payload Throughput Latency
Small body (5 fields) 820K ops/s 0.001 ms
Medium body (20 fields) 215K ops/s 0.005 ms
Large body (100 fields) 39K ops/s 0.025 ms
Nested body (3×5, ~155 fields) 39K ops/s 0.026 ms
skipRoute hit 2.4M ops/s 0.0004 ms

Feature Overhead (20-field object)

Configuration Throughput Overhead
Baseline (defaults) 230K ops/s —
+ stringOptions (trim+lower+max) 214K ops/s ~7%
+ removeMatches 329K ops/s Faster (early exit)
+ removeEmpty 229K ops/s ~0.5%
+ allowedKeys (5 keys) 504K ops/s Faster (fewer keys)
+ onSanitize callback 228K ops/s ~1%

Core Operations

Operation Throughput
Clean string (no match) 36M ops/s
Dirty string ($ prefix) 31M ops/s
Email (fast-path skip) 31M ops/s
isPlainObject check 124M ops/s
isEmail check 36M ops/s
Exact skipRoute match (10 routes) 33M ops/s

Run benchmarks locally: yarn bench or node --expose-gc benchmarks/index.js --save

🛠 Architecture

The project is structured as a monorepo for maximum consistency:

  • core/: The brain. Contains the logic, default patterns, and options resolver.
  • express/ & fastify/: Thin, framework-specific wrappers that adapt the core to each ecosystem.

🧪 Development & Testing

# Clone and install
git clone https://github.com/ExorTek/nosql-sanitize.git
cd nosql-sanitize
yarn install

# Run tests
yarn test          # All suites
yarn test:core     # Core only
yarn test:express  # Express integration
yarn test:fastify  # Fastify integration

# Benchmark
yarn bench                              # Console output only
yarn bench:save                         # Save JSON + Markdown report
yarn bench:compare                      # Save and compare with last run
node --expose-gc benchmarks/index.js    # Most accurate (with GC)

🤝 Contributing

Contributions are welcome. See CONTRIBUTING.md for the setup, branch/commit conventions, and the yarn verify gate CI runs on every PR. In short:

yarn install
yarn verify   # format:check + unit tests + Express/Fastify 4+5 integration servers

🛡 Security

For the supported-version matrix, private vulnerability reporting, and the sanitizer's hardening guarantees, see SECURITY.md. Please do not open a public issue for a security bug.

📜 License

MIT — Created by ExorTek

About

nosql-sanitize is a lightweight and blazing-fast suite of tools designed to prevent NoSQL Injection attacks in Node.js applications. It recursively sanitizes user-supplied data to remove characters and patterns (like $ and control characters) used in malicious MongoDB queries.

Topics

Resources

Contributing

Security policy

Stars

7 stars

Watchers

1 watching

Forks

Releases

Used by

Contributors

Languages