Node.js powers a massive portion of the modern web. From Express APIs to serverless Lambda functions to full-stack Next.js applications, JavaScript on the server is everywhere. And with that ubiquity comes a specific set of security vulnerabilities that generic code scanners consistently miss.
When we conduct secure code reviews for Node.js applications, we're not just running a linter. We're manually tracing data flows, examining trust boundaries, and looking for patterns that lead to remote code execution, data exfiltration, and privilege escalation. Here's our checklist.
Prototype Pollution
Prototype pollution is one of the most dangerous and most misunderstood vulnerabilities in the JavaScript ecosystem. It occurs when an attacker can modify Object.prototype through user-controlled input, which then affects every object in the application.
The attack surface is any function that recursively merges or clones objects from user input. Libraries like lodash.merge, deepmerge, and hand-rolled deep merge functions are common vectors.
function deepMerge(target, source) {
for (const key in source) {
if (typeof source[key] === 'object') {
if (!target[key]) target[key] = {};
deepMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
// Attacker sends: {"__proto__": {"isAdmin": true}}
deepMerge({}, userInput);
// Now EVERY object has isAdmin === true
Secure: Validate keys before merging
function safeMerge(target, source) {
for (const key of Object.keys(source)) {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
continue; // Skip dangerous keys
}
if (typeof source[key] === 'object' && source[key] !== null) {
if (!target[key]) target[key] = {};
safeMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
// Or better: use Object.create(null) for untrusted data
const safeObj = Object.create(null);
Object.assign(safeObj, userInput);
In our top code review findings, prototype pollution appears in roughly one out of every four Node.js assessments. The impact ranges from authentication bypass (polluting isAdmin or role properties) to full remote code execution when combined with template engines or child process spawning.
NoSQL Injection
SQL injection gets all the headlines, but Node.js applications frequently use MongoDB, and MongoDB queries are built from JavaScript objects. When user input is passed directly into a query without type validation, attackers can inject query operators.
Vulnerable: Direct user input in MongoDB query// POST /login with body: {"username": "admin", "password": {"$ne": ""}}
app.post('/login', async (req, res) => {
const user = await db.collection('users').findOne({
username: req.body.username,
password: req.body.password // This accepts objects!
});
if (user) {
// Attacker is now authenticated as admin
req.session.user = user;
}
});
Secure: Type-check inputs and use sanitization
const mongo = require('mongo-sanitize');
app.post('/login', async (req, res) => {
// Ensure inputs are strings, not objects
const username = String(req.body.username || '');
const password = String(req.body.password || '');
// Or use mongo-sanitize to strip $ operators
const user = await db.collection('users').findOne({
username: mongo.sanitize(username),
password: mongo.sanitize(password)
});
// Better yet: hash the password and compare hashes
});
The core issue is that Express (with express.json() middleware) will parse JSON objects, arrays, and nested structures from request bodies. When your code expects a string but receives {"$gt": ""}, MongoDB interprets it as a query operator. This bypasses authentication, exfiltrates data, and in some cases allows full database enumeration.
Command Injection via child_process
The child_process module is the most direct path to remote code execution in Node.js. Any time user input reaches exec(), execSync(), or the shell option of spawn(), the application is vulnerable to command injection.
const { exec } = require('child_process');
app.get('/lookup', (req, res) => {
const domain = req.query.domain;
// Attacker sends: domain=example.com; cat /etc/passwd
exec(`nslookup ${domain}`, (error, stdout) => {
res.send(stdout);
});
});
Secure: Use execFile() with argument arrays
const { execFile } = require('child_process');
app.get('/lookup', (req, res) => {
const domain = req.query.domain;
// Validate input format
if (!/^[a-zA-Z0-9.-]+$/.test(domain)) {
return res.status(400).send('Invalid domain');
}
// execFile does NOT use a shell - arguments are passed directly
execFile('nslookup', [domain], (error, stdout) => {
res.send(stdout);
});
});
We flag every instance of exec(), execSync(), spawn(cmd, { shell: true }), and backtick template usage with child_process functions. The fix is always the same: use execFile() or spawn() without the shell option, pass arguments as arrays, and validate input against a strict allowlist.
Insecure Deserialization
Node.js applications that use node-serialize, serialize-javascript, or custom serialization for session data, caching, or inter-service communication can be vulnerable to deserialization attacks that lead to remote code execution.
const serialize = require('node-serialize');
app.get('/profile', (req, res) => {
// Cookie contains serialized user object
const cookie = Buffer.from(req.cookies.profile, 'base64').toString();
// This can execute arbitrary code via IIFE in serialized data
const user = serialize.unserialize(cookie);
res.render('profile', { user });
});
// Attacker crafts cookie with:
// {"rce":"_$$ND_FUNC$$_function(){require('child_process').exec('...')}()"}
Secure: Use JSON.parse() with validation
app.get('/profile', (req, res) => {
try {
const cookie = Buffer.from(req.cookies.profile, 'base64').toString();
const user = JSON.parse(cookie); // No code execution possible
// Validate the shape of the data
if (typeof user.name !== 'string' || typeof user.id !== 'number') {
throw new Error('Invalid profile data');
}
res.render('profile', { user });
} catch (e) {
res.status(400).send('Invalid profile');
}
});
Rule of thumb: Never use a deserialization library that can reconstruct functions or class instances from untrusted input. Stick to JSON.parse() for untrusted data, and validate the shape and types of the resulting object before using it.
Path Traversal
Path traversal vulnerabilities allow attackers to read or write files outside the intended directory. In Node.js, this commonly appears in file upload handlers, static file servers, and template rendering logic where user input is used to construct file paths.
Vulnerable: Unvalidated file path constructionconst path = require('path');
const fs = require('fs');
app.get('/download', (req, res) => {
const filename = req.query.file;
// Attacker sends: file=../../../etc/passwd
const filePath = path.join(__dirname, 'uploads', filename);
res.sendFile(filePath);
});
Secure: Resolve and validate the final path
const path = require('path');
app.get('/download', (req, res) => {
const filename = req.query.file;
const uploadsDir = path.resolve(__dirname, 'uploads');
const filePath = path.resolve(uploadsDir, filename);
// Ensure the resolved path is still within the uploads directory
if (!filePath.startsWith(uploadsDir + path.sep)) {
return res.status(403).send('Access denied');
}
res.sendFile(filePath);
});
Note that path.join() does not prevent traversal. It happily resolves ../ sequences. You must use path.resolve() and then verify the resulting absolute path is within the expected directory. This is one of the most common findings in our code review assessments.
Event Loop Blocking and ReDoS
Node.js runs on a single-threaded event loop. Any operation that blocks that loop freezes the entire application. This is a denial-of-service vulnerability that is unique to Node.js and often overlooked in security reviews.
Regular Expression Denial of Service (ReDoS)
Poorly written regular expressions with nested quantifiers can cause catastrophic backtracking when matched against crafted input. A single malicious request can lock the event loop for minutes or hours.
Vulnerable: Regex with catastrophic backtracking// This regex has nested quantifiers - O(2^n) complexity
const emailRegex = /^([a-zA-Z0-9]+\.)+[a-zA-Z]{2,}$/;
app.post('/subscribe', (req, res) => {
// Attacker sends: "aaaaaaaaaaaaaaaaaaaaaaaaaaaa!"
if (emailRegex.test(req.body.email)) {
// Event loop is now blocked for minutes
}
});
Secure: Use validated libraries and timeouts
const validator = require('validator');
app.post('/subscribe', (req, res) => {
// Use a well-tested validation library instead of custom regex
if (!validator.isEmail(req.body.email)) {
return res.status(400).json({ error: 'Invalid email' });
}
// Process subscription
});
Synchronous Operations
We also flag any use of synchronous file system operations (fs.readFileSync, fs.writeFileSync) in request handlers, large JSON.parse() calls without size limits, and CPU-intensive operations that should be offloaded to worker threads.
Dependency Supply Chain Vulnerabilities
The average Node.js application has hundreds of dependencies. The node_modules folder is not a joke. It's an attack surface. Supply chain attacks targeting npm packages are increasing in both frequency and sophistication.
During a code review, we examine:
- Lock file integrity. Is
package-lock.jsoncommitted and actually used in CI/CD? Without it, builds pull the latest compatible versions, which may be compromised - Typosquatting risks. Are any dependencies suspiciously named (
loadashinstead oflodash,crossenvinstead ofcross-env)? - Install scripts. Do any dependencies have
preinstallorpostinstallscripts that execute arbitrary code? - Known vulnerabilities. What does
npm auditshow? Are high/critical findings being ignored? - Abandoned packages. Are critical dependencies maintained? When was the last commit?
- Excessive permissions. Does a color formatting library really need network access?
Real-world example: In 2024, the xz-utils backdoor demonstrated how a patient attacker can compromise a widely-used package by becoming a trusted maintainer over years. The Node.js ecosystem, with its culture of micro-dependencies, is especially vulnerable to this attack pattern.
Additional Checks: The Full Checklist
Beyond the critical vulnerabilities above, a thorough Node.js code review also examines:
| Category | What We Check |
|---|---|
| Authentication | JWT implementation (algorithm confusion, none algorithm, key management), session handling, password storage (bcrypt cost factor), OAuth flows |
| Authorization | IDOR via predictable IDs, missing middleware on routes, horizontal/vertical privilege escalation, GraphQL authorization per field |
| Input Validation | Schema validation on all endpoints (Joi, Zod, ajv), Content-Type enforcement, request size limits, file upload validation |
| Error Handling | Stack traces exposed in production, unhandled promise rejections, error messages leaking internal paths or database details |
| Secrets Management | Hardcoded API keys, secrets in environment variables vs. vault, .env files in version control, secrets in client-side bundles |
| HTTP Security | CORS configuration, security headers (helmet.js), cookie flags (httpOnly, secure, sameSite), CSRF protection |
| Logging | Sensitive data in logs (passwords, tokens, PII), log injection, insufficient audit logging for security events |
Why Automated Tools Are Not Enough
Static analysis tools like ESLint security plugins, Semgrep, and Snyk Code are valuable for catching known patterns. But they have fundamental limitations when it comes to Node.js security:
- They can't follow dynamic data flows. JavaScript's dynamic typing and callback-heavy architecture make it extremely difficult for static tools to trace tainted data from source to sink
- They miss business logic. No tool can determine that your discount code endpoint should not accept negative values
- They generate noise. The false positive rate on Node.js static analysis is high, leading teams to ignore findings
- They don't understand architecture. A manual reviewer understands that your internal microservice is exposed to the internet through a misconfigured API gateway
A manual secure code review combines automated tooling with human expertise. We run the tools, but we also read the code, understand the business context, and find the vulnerabilities that matter.
Ship Node.js with confidence
Our security engineers specialize in Node.js and JavaScript ecosystem security. Get a thorough code review that finds what automated tools miss.