TL;DR / Prototype pollution is an attack where an attacker modifies
Object.prototype, causing injected properties to appear on every object in the application and potentially enabling privilege escalation, XSS, or denial of service.
How It Works
┌────────────┐
│ Attacker │
│ Input │
└────────────┘
│
│ __proto__.isAdmin
│
↓
┌────────────────────┐
│ Object.prototype │ isAdmin = true
└────────────────────┘
│
│
┌────────└─────────────┐
│ │
↓ ↓
┌─────────────┐ ┌──────────────┐
│ All objects │ │ user.isAdmin │
│ inherit │ │ = true ! │
└─────────────┘ └──────────────┘
JavaScript's prototype chain means every object inherits properties from Object.prototype. When an attacker can set a property on Object.prototype, that property becomes visible on every object in the runtime that does not explicitly define its own version of that property. This is prototype pollution -- a class of vulnerability that turns JavaScript's inheritance model into an attack surface.
How Pollution Occurs
The most common vector is unsafe recursive merge or deep clone functions. When a function iterates over the keys of a user-provided object and copies them to a target, it typically handles nested objects by recursing. If the attacker supplies {"__proto__": {"isAdmin": true}}, the merge function follows the __proto__ key into the target's prototype chain and sets isAdmin on Object.prototype.
The key insight is that JSON.parse('{"__proto__": {"isAdmin": true}}') produces a plain object with a regular property called __proto__, not a reference to the actual prototype. But when a merge function encounters this key and does target[key] = source[key], the assignment target["__proto__"]["isAdmin"] = true traverses the prototype chain and modifies Object.prototype directly.
Path-based property setting is another vector. Functions that accept dot-notation paths like set(obj, 'a.b.c', value) can be exploited with paths like __proto__.polluted or constructor.prototype.polluted. Both paths reach Object.prototype.
Exploitation Scenarios
Privilege escalation: If authorization checks use if (user.isAdmin) and the user object does not have an explicit isAdmin property, it falls through to Object.prototype.isAdmin, which the attacker set to true.
XSS via templating: Template engines that check options objects can be poisoned. If a template engine checks options.allowCode to decide whether to evaluate code blocks, polluting Object.prototype.allowCode = true enables code execution in templates that were intended to be safe.
Denial of service: Polluting commonly checked properties like toString or valueOf with non-function values causes TypeError exceptions throughout the application when any object tries to use these methods.
Server-Side vs Client-Side
On the server (Node.js), prototype pollution can escalate to remote code execution. Libraries like child_process.spawn read options from objects -- polluting shell, env, or execPath can hijack process spawning. Polluting NODE_OPTIONS through environment handling has been demonstrated as an RCE vector.
On the client, the impact is typically XSS or logic manipulation. Polluting properties read by frontend frameworks can alter rendering behavior, bypass input validation, or modify security-relevant configuration.
Detection Difficulty
Prototype pollution is hard to detect because the vulnerable code (the merge function) and the exploitable code (the property lookup) are often in completely different libraries. The merge function might be in lodash, and the exploitable lookup might be in an Express middleware, a template engine, or your own code. Neither location is buggy in isolation -- the vulnerability only exists in the interaction.
Prevention Strategies
Use Object.create(null) for lookup maps and configuration objects. Objects created this way have no prototype, so __proto__ is just a regular property with no special behavior. Use Map instead of plain objects for dynamic key-value storage.
In merge/clone functions, skip the keys __proto__, constructor, and prototype explicitly. Using Object.keys() is better than for...in (which traverses the prototype chain and enumerates inherited polluted properties), but Object.keys() alone is not a defense -- it still returns __proto__ from JSON-parsed objects. Always combine it with a key blocklist.
Object.freeze(Object.prototype) is a nuclear option that prevents any modification to the prototype. It works but breaks libraries that intentionally extend Object.prototype (rare in modern code but not unheard of).
Input validation with JSON Schema that rejects __proto__ and constructor keys provides defense at the API boundary.
Gotchas
JSON.parseis safe -- it creates plain objects with__proto__as a regular data property. The pollution happens when another function copies that property onto a target object using bracket notation.Object.keys()does return__proto__from JSON-parsed objects where it exists as a data property. Merge functions must explicitly skip__proto__,constructor, andprototypekeys regardless of enumeration method.hasOwnPropertychecks prevent exploitation at the lookup site, but you cannot add them to every property access in third-party code. Prevention at the merge site is more practical.- Lodash
_.mergewas historically vulnerable and patched multiple times. Always use the latest version of utility libraries and audit deep merge behavior. constructor.prototypeis an alternative path toObject.prototypethat many filters miss. Blocking only__proto__is insufficient.