CWE-1321: Improperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution')
Overview
Every object literal in JavaScript inherits from Object.prototype, and a property written onto that one object is visible from every other object in the process that does not shadow it. Prototype pollution is the weakness where untrusted input reaches that write - typically because a key from a request body is used to index into an object graph, and one of the keys along the way is __proto__, constructor or prototype.
The parse step is not the pollution step, and confusing the two sends the fix to the wrong place. JSON.parse('{"__proto__":{"isAdmin":true}}') creates an ordinary own property named __proto__ - measured on Node 24.3.0, Object.getOwnPropertyNames() on the result returns [ '__proto__' ] and Object.prototype is untouched. Express is the same: express.json() on Express 5.2.1 hands the handler a body carrying that own key, with nothing polluted yet. The payload is inert data until something walks it - a recursive merge, a set(obj, path, value) helper, a config loader - and that walk is what performs the write.
Primary Defence: Do not walk untrusted keys into an object graph. Validate the input against a schema and use the parsed result, or hold user-controlled keys in a Map or a null-prototype object. Where a deep merge is needed, use a maintained implementation that refuses these keys rather than writing one.
Relationship to Other CWEs
- CWE-1321 (this page) - the JavaScript case, where the attribute lands on a prototype and the blast radius is every object in the process rather than one record.
- CWE-915 - the parent, and the general case: untrusted input decides which attributes of an object get set. A mass-assignment finding that sets
isAdminon a user document is CWE-915; the same request settingisAdminonObject.prototypeis CWE-1321. - CWE-502 - where prototype pollution often arrives. It is the deserialization risk that survives the move to
JSON.parse: the parser is safe, the code that consumes its output frequently is not. The CWE-502 JavaScript page covers the merge shape from the deserialization side. - CWE-913 - where MITRE's simplified mapping (view-1003) puts CWE-1321 directly, while the research view (view-1000) reaches it through CWE-915. A scanner following view-1003 reports CWE-1321 for anything in this family, including a plain mass-assignment finding with no prototype in it - check what the code actually writes to before re-triaging.
Risk
High: The write is process-wide and lasts for the life of the process, so a single request can change how every later request behaves, including unauthenticated ones. What that is worth to an attacker depends on the gadget - some code that reads a property it never set. An options object checked with if (opts.isAdmin), a template engine reading an inherited field, or a library that branches on an option the caller never passed all turn a polluted prototype into privilege escalation, authentication bypass or denial of service. Where a polluted property reaches a child_process option or a template compiler, it reaches remote code execution.
Common Vulnerable Patterns
Recursive merge of a parsed request body
// VULNERABLE - the merge walks whatever keys the request supplies
function merge(target, source) {
for (const key of Object.keys(source)) {
if (source[key] && typeof source[key] === 'object') {
target[key] = target[key] || {};
merge(target[key], source[key]); // recurses into Object.prototype
} else {
target[key] = source[key];
}
}
return target;
}
app.post('/settings', express.json(), (req, res) => {
const settings = merge({ theme: 'light' }, req.body);
res.json(settings);
});
// Attack: POST /settings {"theme":"dark","__proto__":{"isAdmin":true}}
// Result on Node 24.3.0: ({}).isAdmin === true, for every object in the process
Why this is vulnerable:
Object.keys(source)includes__proto__, becauseJSON.parsecreated it as an own property rather than as a prototype change. The merge then evaluatestarget['__proto__'], which readsObject.prototype- a truthy object, so the|| {}never fires - and the recursive call assignsisAdminonto it.- The obvious fix is not the fix. Rejecting the literal key
__proto__leaves{"constructor":{"prototype":{"isAdmin":true}}}, which reaches the same object by a different route; both were measured to pollute.
A path helper with an attacker-controlled path
// VULNERABLE - the path decides which object gets written
function set(obj, path, value) {
const keys = path.split('.');
let node = obj;
for (const key of keys.slice(0, -1)) {
node[key] = node[key] || {};
node = node[key];
}
node[keys.at(-1)] = value;
}
app.patch('/profile', express.json(), (req, res) => {
set(profile, req.body.field, req.body.value); // field comes from the request
res.sendStatus(204);
});
// Attack: {"field":"__proto__.isAdmin","value":true}
// Attack: {"field":"constructor.prototype.isAdmin","value":true}
Why this is vulnerable:
- The intermediate
node = node[key]step is the whole weakness: oncekeyis__proto__,nodeisObject.prototype, and the final assignment writes a property onto it. Measured on Node 24.3.0, both payloads above leave({}).isAdmin === true. - Validating
valuedoes nothing here. It is the path, not the value, that chooses the target object.
A single dynamic write, which is the near miss
// VULNERABLE - but not in the way it is usually described
const target = {};
const key = req.body.key; // "__proto__"
target[key] = req.body.value; // { isAdmin: true }
Why this is vulnerable:
- This one does not pollute
Object.prototype.target['__proto__'] = objinvokes the inherited__proto__setter, which replaces that object's prototype; measured on Node 24.3.0,Object.getPrototypeOf(target)changes and({}).isAdminstaysundefined.Object.assign(target, JSON.parse(payload))behaves the same way, because it assigns through the setter rather than defining an own property. - It is still a defect, and it is worth knowing which one. The value never becomes an own property, so the object silently does not hold what the code just put in it, and every later read of an unrelated key now resolves against an attacker-supplied prototype. It becomes pollution the moment any code indexes one level deeper - which is what the two patterns above do.
- Spread is different again:
{ ...JSON.parse(payload) }defines__proto__as an own data property and leaves the prototype alone, so the copy carries a live payload into whatever consumes it next.
Secure Patterns
Validate against a schema and use the parsed result
// SECURE - the object that reaches application code is built from declared fields only
const { z } = require('zod');
const settingsSchema = z.object({
theme: z.enum(['light', 'dark']),
pageSize: z.number().int().min(10).max(100),
});
app.post('/settings', express.json(), (req, res) => {
const settings = { ...defaults, ...settingsSchema.parse(req.body) };
res.json(settings);
});
Why this works:
- The parsed result contains only the keys the schema declares. Measured on Zod 4.4.3, parsing
{"a":1,"__proto__":{"polluted":true}}againstz.object({ a: z.number() })returns an object whose own properties are[ 'a' ], withObject.prototypeas its prototype and no payload left to carry. - The return value is what makes this hold, not the validation. Spreading
req.bodyafter validating it puts the payload straight back - the same mistake the CWE-915 JavaScript page records for mass assignment. Spread the parsed object.
Hold user-controlled keys in a Map or a null-prototype object
// SECURE - a Map has no prototype chain to walk into
const counters = new Map();
counters.set(req.body.key, (counters.get(req.body.key) ?? 0) + 1);
// SECURE - a null-prototype object where a plain object is required
const bag = Object.create(null);
bag[req.body.key] = req.body.value; // '__proto__' becomes an ordinary own key
Why this works:
Mapkeys are values, not property names, so__proto__is a string like any other - measured,new Map(Object.entries(payload)).get('__proto__')returns the attacker's object and nothing inherits from it.Object.create(null)has no__proto__accessor to invoke and noObject.prototypeabove it, so the write that changes an ordinary object's prototype instead creates an own key. Use this for anything that is a flat dictionary of caller-supplied keys - parsed headers, feature flags, per-tenant config.- This protects the target, and the protection is exactly one level deep. Measured on Node 24.3.0: the vulnerable merge above into a null-prototype target leaves
({}).isAdminundefined for{"__proto__":{"isAdmin":true}}, and pollutes for{"profile":{"__proto__":{"isAdmin":true}}}- becausebag.profileis created by the helper's owntarget[key] || {}as an ordinary object, and the recursion walks through that one. Neither of these patterns fixes a walker; use the schema above or the maintained merge below for that, and read a null-prototype target as hardening for the flat case rather than as the fix.
Use a maintained merge rather than writing one
// SECURE - lodash refuses the three prototype keys internally
const _ = require('lodash');
const settings = _.merge({}, defaults, req.body);
_.set(config, allowedPath, value);
Why this works:
- Measured on lodash 4.18.1,
_.merge,_.defaultsDeepand_.setall leaveObject.prototypeclean when handed{"__proto__":{"polluted":true}}or the path__proto__.p2. The check is inside the library, so it holds for every call site rather than for the one someone remembered to guard. - This is the same reasoning as using a library encoder instead of a hand-written one: the hard part is the list of keys and the routes to them, and that part is where hand-rolled versions fail. Keep the dependency current - the protections were added in response to CVEs, and a vendored copy that has not been updated does not have them.
Harden the runtime as defence in depth
// SECURE (defence in depth) - run this at startup, before any request is served
Object.freeze(Object.prototype);
// Or start the process with the prototype accessor disabled entirely:
// node --disable-proto=throw server.js
Why this works:
- Freezing makes the write fail. Measured on Node 24.3.0, the vulnerable merge above leaves
Object.prototypeclean once it is frozen. Know which failure you get: in strict mode or an ES module the assignment throwsTypeError: Cannot add property x, object is not extensible, and in sloppy mode it fails silently - so a frozen prototype can turn an exploit into an unexplained "the setting did not save". --disable-proto=throwremoves the__proto__accessor: measured,obj['__proto__']['x'] = 1throwsError: Accessing Object.prototype.__proto__ has been disallowed. It does not touch theconstructor.prototyperoute, andJSON.parsestill produces the own__proto__key, so it narrows the attack surface rather than closing it.- Both are process-wide changes and neither replaces the primary fix. They are worth having where the risk is a dependency you do not control.
Considerations
- Exploitability depends on finding a gadget, and the absence of one today is not a fix. Look for the reads -
opts.timeout ?? 30,if (config.debug), a template engine resolving an inherited field - across the whole process, including in dependencies. A gadget can also arrive with the nextnpm update, which is why "no gadget found" is a reason to prioritise lower rather than to close. - Freezing
Object.prototypeis a compatibility decision as much as a security one. Libraries that extend the prototype at load time will fail, and in sloppy mode they fail silently. Freeze after the module graph has loaded, and put it through a full staging run rather than shipping it with the patch. - Where the data really is a dictionary of user-controlled keys, sanitising the keys is the wrong shape of answer. A
Mapremoves the question. Reach for key filtering only where an existing object-shaped API cannot be changed. - Scope the fix to every walker, not to the reported one. The finding names one merge; the same codebase usually holds a config loader, a query-string expander and a test helper doing the same walk. The reported line is a sample.
Common Pitfalls
- Filtering keys at the top level only. Rejecting
__proto__fromObject.keys(req.body)before the merge misses{"profile":{"__proto__":{"isAdmin":true}}}, because the dangerous key is one level down and the recursion reaches it anyway. The check has to sit inside the walk, at every level, which is most of the argument for using a maintained merge instead. - Treating a parsed body as safe data. The own
__proto__key survives everything that re-walks keys: measured on Node 24.3.0,structuredClonepreserves it, and so does any hand-written deep copy. Passing a request body through a clone or a queue does not clean it. - Assuming the body parser handled it. Content types behave differently, measured on Express 5.2.1 with the same
__proto__payload:express.urlencoded({ extended: true })drops the key outright, becauseqs6.15.3 refuses it;express.urlencoded({ extended: false })keeps__proto__[isAdmin]as one literal key name it never expands; andexpress.json()passes a real, nested__proto__object through as an own property. None of the three pollutes anything by itself, but only the JSON body arrives carrying a payload a merge can act on. Testing the form-encoded route and concluding the application is safe is a false negative. - Fixing the spread and leaving the merge.
Object.assignand spread are the two shapes that read as dangerous and are not the ones that pollute; replacing them while the recursive merge stays touches the wrong lines. Find the code that indexes intonode[key]and writes one level deeper. - Allowlisting the prefix of a path and not the rest of it. With the hand-written helper above,
set(config, 'features.' + req.body.name, value)is still exploitable whennameisx.__proto__.isAdmin: the allowlist governed the part the attacker did not control, and the walk still reaches the prototype through the part they did.
Testing
Re-running the detector proves the pattern is gone from one file, not that the process is clean, and a fix that filters keys can also break legitimate nested updates - so assert both directions.
- Malicious input, JSON route: POST
{"__proto__":{"isAdmin":true}}, then assert({}).isAdmin === undefinedby reading a freshly created object rather than the response body. An endpoint that echoes what it was sent reports the attacker's key either way. - Malicious input, the other route: repeat with
{"constructor":{"prototype":{"isAdmin":true}}}and with the payload nested one level down ({"profile":{"__proto__":{"isAdmin":true}}}). A fix that passes the first and fails these two is a denylist. - Persistence across requests: assert the clean value from a second, unauthenticated request after the malicious one. Pollution outlives the request that caused it, and a test that only checks the polluting response misses that entirely.
- Normal input: confirm a legitimate nested update still merges -
{"profile":{"theme":"dark"}}leavessettings.profile.theme === 'dark'and does not drop sibling keys. This is what catches an over-eager filter. - Prototype identity: in unit tests over the parsing or merging helper, assert
Object.getPrototypeOf(result) === Object.prototypeandObject.getOwnPropertyNames(result)contains no prototype key. That catches theObject.assignshape, which changes the object's own prototype without polluting anything global.