Write to __proto__ once and you've quietly added a property to every object in the program.
In JavaScript, almost every object shares one ancestor: Object.prototype. When you read obj.foo and obj has no own foo, the engine walks up the prototype chain to that shared parent. That sharing is normally invisible and useful.
Prototype pollution abuses it. A naive deep-merge that copies attacker-controlled keys can follow the key __proto__ straight to Object.prototype and write there. From that moment the planted property leaks into unrelated objects across the whole process — a single request can flip an auth check, crash a server, or worse, depending on which later sink reads it.
The danger is not parsing the JSON — it is the recursive merge that follows the attacker's keys. This version has no key guard, so the key "__proto__" resolves to the shared parent instead of becoming an own property:
// VULNERABLE: copies any key, including __proto__
function deepMerge(target, source) {
for (const key in source) {
if (typeof source[key] === 'object' && source[key] !== null) {
if (typeof target[key] !== 'object') target[key] = {};
deepMerge(target[key], source[key]); // recurse
} else {
target[key] = source[key]; // write
}
}
return target;
}
const payload = JSON.parse('{"__proto__":{"isAdmin":true}}'); // parse is fine
deepMerge({}, payload); // walks "__proto__" -> Object.prototype
({}).isAdmin; // -> true (leaked into a brand-new object)
The fix is to refuse the dangerous keys, or to merge into an object that has no shared prototype at all:
const BANNED = new Set(['__proto__', 'constructor', 'prototype']);
function safeMerge(target, source) {
for (const key of Object.keys(source)) {
if (BANNED.has(key)) continue; // 1) guard keys
const v = source[key];
if (v && typeof v === 'object') {
if (!Object.hasOwn(target, key)) target[key] = {};
safeMerge(target[key], v);
} else {
target[key] = v;
}
}
return target;
}
// Stronger options, used together or alone:
const bag = Object.create(null); // no prototype to pollute
const m = new Map(); // keys never touch a prototype
Object.freeze(Object.prototype); // process-wide backstop
One malicious request mutates global state shared by every object. Downstream impact runs from a quiet logic bypass (an auth flag flips), to denial of service (a planted property breaks unrelated code), to remote code execution (when a later sink reads the polluted value as a command or template). The cost is set by whichever sink reads the planted property next, not by the merge itself.
"__proto__" misses constructor.prototype — a nested payload like {"constructor":{"prototype":{"isAdmin":true}}} reaches the same parent. Ban all three of __proto__, constructor, prototype.JSON.parse itself is safe. The vulnerable part is the unsafe merge or deep-set that walks the parsed keys afterward. Hardening the parser is not the fix; hardening the merge is.lodash.merge / lodash.set, query-string and form parsers, and config loaders have all shipped this exact bug at some point. Recursive copy of untrusted keys is the shared smell.key === '__proto__' and then still doing obj[key] = ... via bracket assignment can resolve the prototype anyway on some paths. Prefer Object.hasOwn, an Object.create(null) bag, or a Map. And deep-cloning before merge does not help if the cloner has the same flaw.Feed {"__proto__":{"isAdmin":true}} to the unsafe deepMerge. The loop hits the key "__proto__". Because the merge writes into target[key] with no guard, that lookup does not create an own property on target — it resolves to the shared Object.prototype, and isAdmin: true lands there.
Now, somewhere unrelated, a request handler creates a fresh user = {} and runs an authorization check:
const user = {}; // brand new, never touched the payload
if (user.isAdmin) grantAdmin(); // user has no own isAdmin...
The read user.isAdmin finds nothing on user, walks up the chain to the now-polluted Object.prototype, and returns true. The auth check passes for everyone, because every plain object inherits the same poisoned parent. No exploit code ran in the handler — the breach was planted earlier and simply waited for a sink to read it.
Besides __proto__, which key chain can also reach Object.prototype in an unguarded deep-set?
Is JSON.parse(payload) the vulnerable step here?