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.
- 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, andprototypekeys 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.
- Packages
- Why?
- Quick Start
- Compatibility
- Configuration Options
- Security
- Advanced Usage
- Performance
- Architecture
- Development & Testing
- Contributing
- License
| 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 |
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 |
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": "" } }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!');
});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!' };
});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. |
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)
}
}));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
}
}));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.
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 }));The maxDepth option limits how deep the sanitizer recurses into nested objects. This is important for two reasons:
- Performance: Deeply nested payloads can cause excessive CPU usage.
- 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 thanmaxDepthand 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.
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 custompatternsyou provide. If you add custom patterns that must apply to email values, setpreserveEmails: false.It only applies to values.
deniedKeysalways win, and email-looking keys still get their dots replaced (seedotKeys).
// Custom patterns that must also apply to emails
app.use(mongoSanitize({
preserveEmails: false,
patterns: [/\$/g, /@evil\.com/g],
}));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 }));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.isAdminbecomeprofileisAdmin(dotKeys: 'replace'). Still, never spread request bodies straight into$set. - Anchor
skipRoutesregexes (/^\/health$/, not/health/). Otherwise they match anywhere in the path.
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 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 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.
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 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.
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 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'tosanitizeObjectshas no effect in Express. App-level middleware runs before routing, soreq.paramsis still empty at that point.
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).
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);
}
}));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 } }Instead of removing $, replace it with an underscore:
app.use(mongoSanitize({
replaceWith: '_'
}));
// { "$gt": 5 } -> { "_gt": 5 }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.
Benchmarked on Apple M4 Pro (14 cores), Node.js v24.x with --expose-gc:
| 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 |
| 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% |
| 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 benchornode --expose-gc benchmarks/index.js --save
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.
# 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)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 serversFor 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.
MIT — Created by ExorTek
