Prototype Pollution
JS-specific Object.prototype mutation. lodash/jQuery/express. Object.create(null) defense.
Prototype Pollution
A bug class that only exists in JavaScript, where it has been the source of dozens of CVEs across lodash, jQuery, Express, set-value, mongoose, hoek, and many more. The TL;DR: every plain JS object shares one mutable global thing — Object.prototype — and if attacker input can write to it, they get to inject properties into every object the application uses.
The fundamental shape
const o = {};
o.foo; // undefined — no own property, no inherited property either
Object.prototype.foo = "polluted";
o.foo; // "polluted" — every object that doesn't shadow `foo` now has it
That's the bug. The polluter doesn't need to touch the specific object; modifying the prototype is enough to leak the property to every consumer.
The polluter key is usually __proto__:
const x = {};
x.__proto__.isAdmin = true; // same as Object.prototype.isAdmin = true
({}).isAdmin // → true
Or constructor.prototype (slightly more roundabout):
const x = {};
x.constructor.prototype.isAdmin = true; // same outcome
How attacker JSON triggers it
Most apps deep-merge user input into config or session objects. Something like:
function deepMerge(target, source) {
for (const key of Object.keys(source)) {
if (typeof source[key] === "object") {
target[key] = target[key] ?? {};
deepMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
The attacker sends:
{ "__proto__": { "isAdmin": true } }
Walk through it. Object.keys({ "__proto__": { … } }) returns ["__proto__"]. The merge writes into target["__proto__"] — which is, well, target.__proto__ — which is Object.prototype. The deeper merge writes Object.prototype.isAdmin = true. Every object now has isAdmin = true. The auth middleware that checks req.user.isAdmin returns true for every request.
Real CVEs of exactly this shape:
- lodash CVE-2019-10744 (
defaultsDeep): full prototype pollution via__proto__, fixed in 4.17.12. - lodash CVE-2018-3721 (
merge,mergeWith): similar. - lodash CVE-2020-8203 (
zipObjectDeep): another deep-merge variant. - jQuery $.extend(true, , …) (CVE-2019-11358): same bug class.
- set-value, set-deep-prop, dot-prop, hoek, mongoose: dozens of similar.
What it enables
Once Object.prototype is polluted, the impact depends on what the app does next:
- Auth bypass: any code that checks
if (req.user.isAdmin)reads the polluted truthy value. - RCE in some frameworks: if the app calls
child_process.spawnwith options derived from user objects, the attacker can injectshellor other dangerous flags. Documented in pug, ejs, jade, Handlebars, and other template-engine SSR setups. - Denial of service: pollute methods like
toStringto throw on every operation. - Data corruption: pollute serialization defaults, configuration constants.
The blast radius is "anywhere in the app that touches an object."
Defenses
Stack them. None alone is bulletproof.
1. Validate / allowlist input keys
Refuse __proto__, constructor, prototype in any user input that gets merged into objects:
const FORBIDDEN = new Set(["__proto__", "constructor", "prototype"]);
function safeMerge(target, source) {
for (const key of Object.keys(source)) {
if (FORBIDDEN.has(key)) continue;
// ... rest of merge logic
}
}
This catches the obvious case. Sophisticated attackers find paths like a.constructor.prototype.x — the validation needs to be on every key in every nested level.
2. Use Object.create(null)
Plain {} objects descend from Object.prototype. Object.create(null) creates an object with no prototype chain at all:
const o = Object.create(null);
o.__proto__; // undefined (no chain)
o.toString; // undefined (the inherited method is gone)
Setting __proto__ on a prototype-less object just adds an own property called __proto__; it does not modify any prototype chain. This is the right shape for any map keyed by user input.
const sessions = Object.create(null) as Record<string, Session>;
sessions[req.userId] = ...; // safe even if req.userId is "__proto__"
3. Use Map
The ES6 Map is prototype-pollution-immune by construction:
const m = new Map();
m.set("__proto__", "totally fine");
m.set(42, "even non-string keys work");
m.get("__proto__"); // "totally fine"
Maps don't share a global mutable space. They're slightly slower for small workloads and a bit more verbose, but they're the right default for any user-keyed collection.
4. Object.freeze(Object.prototype) at boot
// At the top of your entry point, before anything else loads:
Object.freeze(Object.prototype);
Object.freeze(Object.create(null));
After this, Object.prototype.x = "..." silently fails (or throws in strict mode). The attack at the prototype level is foreclosed entirely. Many serious projects ship this.
There's a small list of libraries that monkey-patch Object.prototype at runtime; they will break. Most of those are very old jQuery plugins, MooTools, Prototype.js (the library, ironically). Modern code is fine.
5. JSON.parse strict mode
JSON.parse does NOT process __proto__ as the actual prototype — it creates an own property "__proto__" on the resulting object. So parsing JSON is safe; the dangerous step is merging the parsed object into other state without sanitization.
If your code does:
const cfg = JSON.parse(req.body); // safe by itself
deepMerge(globalConfig, cfg); // ← this is where the bug lands
Replace the merge.
What's safe and what isn't
Roughly:
JSON.parse(userInput)— safe. No prototype mutation.Object.assign(target, userInput)— safe (shallow;__proto__becomes an own property of target).target[key] = valuewherekeyis user-controlled — dangerous. Ifkey === "__proto__", you're mutating the prototype.target.path.to.deep[key] = value— dangerous, transitively.- Deep-merge / deep-set / deep-extend libraries with user input — dangerous unless they explicitly skip prototype keys.
Audit any function in your code or your dependencies that takes a "path" or "key chain" derived from user input. That's where this bug lives.
Detection in production
- ESLint with
eslint-plugin-securityflagsObject.assign(target, userInput)and the related patterns. - npm audit / Snyk catch known CVEs in dependencies, including lodash variants.
- Snyk Code (SAST) finds prototype-pollution sinks across your codebase.
- Object.freeze(Object.prototype) at boot is a single line that costs nothing.
What goes wrong (real)
In 2022, a popular admin panel for a SaaS shipped a config-import feature: paste a JSON blob, the panel would deep-merge it with the live config. The deep-merge was an unaudited utility from a tiny package on npm. A user tried {"__proto__":{"isAdmin":true}}. The next request from any user came back as admin. CVE assigned, panel patched, but the patch was a one-line key-blocklist; the architectural fix (use a Map for the config) shipped 6 months later.
The general lesson: deep-merge with user input is a footgun in JavaScript. There are only two right answers:
- Don't deep-merge user input. Validate-and-set-explicitly each field you accept.
- If you must, use a deep-merge that hardens against prototype keys AND a destination object made with
Object.create(null).
Anything in between is a future CVE.
What to ship
// utils/safe-collections.ts
export function emptyMap<V>() {
return Object.create(null) as Record<string, V>;
}
export const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
export function safeAssign<T extends object>(target: T, source: Record<string, unknown>): T {
for (const key of Object.keys(source)) {
if (FORBIDDEN_KEYS.has(key)) continue;
(target as Record<string, unknown>)[key] = source[key];
}
return target;
}
// In your app entry point:
Object.freeze(Object.prototype);
Use these everywhere user input flows into object state. The bug class disappears.
Tools in the wild
3 tools- libraryESLint security pluginfree tier
Static analysis flags `Object.assign(target, userInput)` and prototype-mutating patterns.
- service
SAST that detects prototype-pollution sinks across the dependency tree.
- specObject.freeze(Object.prototype)free tier
One-line global hardening: prevents any subsequent mutation of Object.prototype.