Prototype Pollution
ID |
javascript.prototype_pollution |
Severity |
high |
Remediation Complexity |
trivial |
Remediation Risk |
low |
Remediation Effort |
low |
Resource |
Injection |
Language |
JavaScript |
Tags |
ASVS50:v1.5.3, CWE:1321, NIST.SP.800-53, OWASP:2025:A03 |
Description
A recursive merge writes target[key] with a key taken from an object the caller supplied, and
never rejects proto, constructor or prototype.
In JavaScript every object shares Object.prototype. Writing through one of those three names does
not set a property on the target — it sets one on the prototype, where every object in the
process can then read it. A request body of {"__proto__": {"isAdmin": true}} merged into a
settings object leaves ({}).isAdmin === true for the lifetime of the worker.
Rationale
The polluted property is visible to code that never touched the request: an authorization check
reading user.isAdmin, a template engine reading an option it believes is its own default, a
library branching on a flag it never set. Depending on what is polluted this is privilege
escalation, denial of service, or — when a gadget exists on the load path — remote code
execution.
JSON.parse produces proto as an own property, so hasOwnProperty does not help: the
guard passes and the write still reaches the prototype.
Vulnerable code
function deepMerge(target, source) {
for (const key of Object.keys(source)) {
if (source[key] && typeof source[key] === 'object') {
if (!target[key]) target[key] = {};
deepMerge(target[key], source[key]);
} else {
target[key] = source[key]; (1)
}
}
return target;
}
router.post('/merge', (req, res) => res.json(deepMerge({...defaults}, req.body)));
| 1 | key comes from the request body, so it may be proto. |
Remediation
Reject the three names before writing:
const FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype']);
function deepMerge(target, source) {
for (const key of Object.keys(source)) {
if (FORBIDDEN.has(key)) continue;
// ...
}
return target;
}
Alternatives that remove the class of bug rather than the instance:
-
Build the accumulator with
Object.create(null), which has no prototype to pollute. -
Use a
Mapinstead of a plain object for caller-controlled keys. -
Use a vetted merge (
lodash.mergefrom 4.17.21, which carries the fix) rather than a local one. -
Run with
--disable-proto=throwon Node 12.17+ to make the write fail loudly.