Prototype Pollution in JavaScript: How It Works and How to Prevent It

Prototype pollution lets attackers inject properties into Object.prototype, affecting every object in a Node.js process. This guide covers the attack mechanics, real-world exploitation paths, and concrete defensive patterns for JavaScript and Node.js applications.

Prototype pollution gets dismissed as a theoretical issue right up until someone chains it to RCE. JavaScript’s prototype chain means that if an attacker can set __proto__ properties on an object, those properties appear on every ordinary object in the process. Depending on how your application uses those objects, that escalates to remote code execution.

How JavaScript Prototypes Work

Every JavaScript object has a prototype, accessible via __proto__ (or Object.getPrototypeOf()). When you access a property that doesn’t exist directly on an object, the engine walks up the prototype chain. Object.prototype sits at the top — properties set there are visible on every ordinary object.

const a = {};
console.log(a.foo); // undefined

Object.prototype.foo = "injected";
console.log(a.foo); // "injected"  --  even though we never set it on 'a'

This is prototype pollution: setting a property on Object.prototype via a user-controlled input path, making it appear on objects throughout the application.

The Vulnerable Pattern

The most common source is recursive merge or deep clone functions — the kind used to merge configuration objects or process nested JSON:

// VULNERABLE: naive recursive merge
function merge(target, source) {
  for (const key of Object.keys(source)) {
    if (typeof source[key] === 'object' && source[key] !== null) {
      if (!target[key]) target[key] = {};
      merge(target[key], source[key]);  // recurse
    } else {
      target[key] = source[key];  // direct assignment  --  danger here
    }
  }
  return target;
}

// Attacker-controlled input:
const maliciousPayload = JSON.parse('{"__proto__": {"admin": true}}');
merge({}, maliciousPayload);

// Now:
const user = {};
console.log(user.admin); // true  --  injected via Object.prototype

The __proto__ key is valid JSON and valid JavaScript. When the merge function recurses into it, it’s operating on Object.prototype itself.

Escalating to RCE

Property injection becomes dangerous when the polluted property is used in sensitive contexts. Two common escalation paths:

Path 1: Child process spawn options

const { execFile } = require('child_process');

// Attacker pollutes:
Object.prototype.env = { NODE_OPTIONS: '--require /tmp/malicious.js' };

// Later in legitimate code:
execFile('node', ['script.js'], {}, callback);
// The options object picks up .env from prototype  --  NODE_OPTIONS injected

Path 2: Template engine code injection

Several popular template engines (lodash’s _.template, Handlebars in older versions) have been shown to execute prototype-polluted values as code. The lodash CVE-2019-10744 is the canonical example — _.set on a deep object path of __proto__.constructor.prototype allowed arbitrary code execution.

// lodash < 4.17.16  --  vulnerable
const _ = require('lodash');
_.set({}, '__proto__.constructor.prototype.polluted', '1');
// Results in Object.prototype.constructor.prototype.polluted being set

// Combined with lodash template:
_.template('<%= polluted %>')({});
// Executes the polluted value as a template expression

Detection: Finding Vulnerable Code

Use these grep patterns or Semgrep rules to find risky merge patterns in your codebase:

// Semgrep pattern to find unsafe merge implementations
// rules/prototype-pollution.yaml
rules:
  - id: unsafe-object-merge
    patterns:
      - pattern: |
          for (... of Object.keys($SRC)) {
            ...
            $TARGET[$KEY] = $SRC[$KEY];
            ...
          }
    message: "Potential prototype pollution in object merge  --  check key filtering"
    languages: [javascript, typescript]
    severity: WARNING

For dependency scanning, npm audit and Snyk flag known prototype pollution CVEs in third-party packages. Run npm audit --json | jq '.vulnerabilities | to_entries[] | select(.value.title | test("prototype pollution"; "i"))' to filter specifically for these.

Prevention Patterns

1. Key filtering in merge functions

const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);

function safeMerge(target, source) {
  for (const key of Object.keys(source)) {
    if (DANGEROUS_KEYS.has(key)) 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;
}

2. Null-prototype objects

For objects used as hash maps or configuration stores, create them with Object.create(null) — they have no prototype, so pollution can’t reach them:

// Safe map  --  no prototype chain
const config = Object.create(null);
config['admin'] = false;

// Prototype pollution has no effect:
Object.prototype.admin = true;
console.log(config.admin); // false  --  no prototype lookups

3. Object.freeze on Object.prototype

For server-side applications, freeze Object.prototype during startup to make it immutable:

// In your application entry point:
Object.freeze(Object.prototype);

// Now attempting pollution throws in strict mode / silently fails otherwise:
const payload = JSON.parse('{"__proto__": {"admin": true}}');
const obj = {};
Object.assign(obj, payload); // __proto__ assignment is a no-op on frozen prototype

Be careful with this approach — some libraries legitimately extend Object.prototype (polyfills, older test frameworks). Test thoroughly before deploying.

4. Use structuredClone or JSON round-trip for deep cloning

Instead of hand-rolled merge functions, use structuredClone() (available in Node.js 17+) which handles the clone safely:

// Safe deep clone  --  structuredClone ignores __proto__
const cloned = structuredClone(userInput);

Or for merging objects, _.mergeWith from lodash 4.17.21+ includes prototype pollution mitigations, though explicit key filtering is still preferred.

5. Schema validation at the boundary

Validate and strip unknown keys from user-supplied objects before they enter any merge or deep-copy operation. With Zod:

import { z } from 'zod';

const UserSchema = z.object({
  name: z.string(),
  role: z.enum(['user', 'editor'])
}).strict(); // .strict() rejects unknown keys

const result = UserSchema.safeParse(req.body);
if (!result.success) {
  return res.status(400).json({ error: result.error });
}
// result.data is clean  --  no __proto__ or unknown keys

Libraries to Audit

If you’re using any of these, verify you’re on patched versions or have mitigations in place:

  • lodash — multiple prototype pollution CVEs; use ≥ 4.17.21
  • merge npm package — CVE-2018-3728; use deepmerge instead
  • jquery$.extend() was vulnerable; patched in 3.4.0
  • minimist — CVE-2020-7598; use yargs or newer arg parsers
  • qs — CVE-2014-7191 (ancient but still in some dependency trees)

Prototype pollution is a class of vulnerability that’s easy to overlook in code review because the dangerous assignment (target[key] = source[key]) looks completely normal. Key filtering at merge boundaries and null-prototype objects for maps are the two controls that provide the most reliable protection.