---
title: Secure Code Review Checklist for Node.js Applications
description: Prototype pollution, NoSQL injection, command injection, and insecure deserialization. The Node.js-specific vulnerabilities we find in every code review and how to fix them.
date: 2026-02-26
category: Code Reviews
read_time: 11 min read
author: Lorikeet Security
url: https://lorikeetsecurity.com/blog/nodejs-security-code-review
---

# Secure Code Review Checklist for Node.js Applications

[
        ** Back to Blog
    ](/blog)












            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](/blog/secure-code-review-process) 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.



        Vulnerable: Deep merge without prototype check


```
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](/blog/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.



        Vulnerable: User input in exec()


```
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.



        Vulnerable: Deserializing untrusted data


```
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 construction


```
const 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](/blog/top-code-review-findings).





---




## 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](/blog/supply-chain-security-dependencies) targeting npm packages are increasing in both frequency and sophistication.






            During a code review, we examine:





            - **Lock file integrity.** Is `package-lock.json` committed 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 (`loadash` instead of `lodash`, `crossenv` instead of `cross-env`)?

            - **Install scripts.** Do any dependencies have `preinstall` or `postinstall` scripts that execute arbitrary code?

            - **Known vulnerabilities.** What does `npm audit` show? 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](/blog/secure-code-review-process) 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.




                [** Book a Code Review](/contact#booking)
                [View All Services](/service-areas)






Link copied!