Prototype pollution is the JavaScript vulnerability that sounds academic until you watch it escalate to remote code execution. It exploits something fundamental to the language — every object inherits from a shared Object.prototype — so a single attacker-controlled key can silently change the behavior of every object in your process. It has appeared in lodash, minimist, jQuery, and countless application-level merge functions. This guide explains precisely how it works, why it's so damaging, and the layered defenses that shut it down.
The one-paragraph mental model
In JavaScript, when you read obj.foo and obj doesn't have its own foo, the engine walks up the prototype chain to Object.prototype.foo. Almost every object shares that same Object.prototype. So if an attacker can make your code assign to Object.prototype.foo, then suddenly every object in your program appears to have a foo property. Pollute one prototype, affect everything.
How the injection happens
The vulnerable code is almost always a function that copies keys from untrusted input into an object using bracket notation — a deep merge, a set(obj, path, value) helper, or a query-string parser.
function setDeep(obj, path, value) {
const keys = path.split(".");
let cur = obj;
for (let i = 0; i < keys.length - 1; i++) {
cur = cur[keys[i]] = cur[keys[i]] || {};
}
cur[keys[keys.length - 1]] = value;
}
// Attacker sends path = "__proto__.isAdmin", value = true
setDeep({}, "__proto__.isAdmin", true);
// Now, for ANY object created afterward:
const someUser = {};
console.log(someUser.isAdmin); // true ← polluted
The same happens with recursive merges when the source is parsed from JSON like {"__proto__": {"isAdmin": true}}, or through the constructor.prototype chain, which reaches the same place by a different path.
Why it ranges from annoying to catastrophic
The impact depends entirely on what your code — or a downstream library — does with the polluted property afterward:
- Authorization bypass: code that checks
if (user.isAdmin)now passes for everyone. - Denial of service: polluting a property the runtime reads internally can crash or hang the process.
- Remote code execution via gadget chains: this is the severe case. If any library later reads a polluted property and feeds it into a dangerous sink — a template engine that concatenates it into
Function(), or a child-process call that reads a pollutedshell/argvoption — the attacker escalates from setting a boolean to running commands. Several documented Node.js RCEs chained prototype pollution into exactly this kind of gadget.
Four layers of defense
Layer 1 — Reject dangerous keys at the boundary
Any function that copies untrusted keys must refuse __proto__, constructor, and prototype:
const BLOCKED = new Set(["__proto__", "constructor", "prototype"]);
function safeSet(obj, path, value) {
const keys = path.split(".");
if (keys.some((k) => BLOCKED.has(k))) throw new Error("illegal key");
// ...proceed
}
Filtering keys is necessary but not sufficient on its own — do it and the layers below.
Layer 2 — Use data structures with no prototype to pollute
For any map built from user input, drop the prototype chain entirely:
const bag = Object.create(null); // no __proto__, no inherited props
bag[userKey] = userValue; // nothing to pollute
// Or use Map, which never touches Object.prototype:
const m = new Map();
m.set(userKey, userValue);
Map is the cleanest answer whenever keys are attacker-controlled: it has no prototype-chain semantics at all.
Layer 3 — Freeze the prototype so writes fail
Freezing Object.prototype makes pollution attempts throw (in strict mode) or silently no-op instead of succeeding:
Object.freeze(Object.prototype);
Object.freeze(Object);
Node offers this at the runtime level too. Launch with --frozen-intrinsics to freeze built-in prototypes, or --disable-proto=delete to remove the __proto__ accessor from Object.prototype outright — a strong, low-effort mitigation for services that handle untrusted JSON.
Layer 4 — Validate with a schema
A strict schema rejects unexpected keys before they ever reach a merge. Define exactly the shape you accept and discard the rest:
import { z } from "zod";
const Settings = z.object({ theme: z.enum(["light", "dark"]) }).strict();
Settings.parse(input); // extra keys like __proto__ → validation error
A defense checklist
| Layer | Control | Effort |
|---|---|---|
| Boundary | Block __proto__/constructor/prototype keys | Low |
| Data model | Object.create(null) or Map for user maps | Low |
| Runtime | --disable-proto=delete, --frozen-intrinsics | Very low |
| Schema | .strict() validation at every input | Medium |
| Dependencies | Keep merge libs patched; scan the graph | Ongoing |
The dependency angle you can't skip
Most prototype-pollution incidents don't come from your code — they come from a transitive dependency's merge or path-set helper. That's why the lodash (CVE-2019-10744) and minimist (CVE-2020-7598) advisories mattered so widely. You need to know which of your dependencies carry a known prototype-pollution CVE and whether your code path actually reaches the vulnerable function.
How Safeguard helps
Safeguard's software composition analysis identifies prototype-pollution CVEs across your full dependency tree and runs reachability analysis so you can tell the difference between a vulnerable function you actually call and one buried in an unused code path — the distinction that separates a real risk from noise. When a fixed version exists, automated fix pull requests bump the dependency and run it against your lockfile so remediation is a review, not a research project. Griffin AI traces the potential gadget chain from a polluted property to a dangerous sink and explains whether the finding is exploitable in your context. Compared with version-only scanners — see Safeguard vs Snyk — the reachability layer is what keeps this from becoming another wall of alerts.
Get started
Prototype pollution is defeated by defense in depth: block the keys, drop the prototype, freeze the intrinsics, and validate the schema — then let scanning cover the dependencies you don't control. Create a free account at app.safeguard.sh/register and read the reachability analysis docs at docs.safeguard.sh.