The __proto__ Key in an API Response Is a Merge Bug Waiting to Happen
Every JSON formatter shows it plainly: a key named __proto__ sitting in the middle of an otherwise normal API response. It looks harmless because nothing crashes. That is exactly the problem — the bug shows up two files
Every JSON formatter shows it plainly: a key named __proto__ sitting in the middle of an otherwise normal API response. It looks harmless because nothing crashes. That is exactly the problem — the bug shows up two files later, in the code that copies the object.
I tested this in Node 22 with one payload:
{"__proto__":{"polluted":"yes"},"ok":1}
JSON.parse() itself is safe. The key becomes an ordinary own property:
const obj = JSON.parse(raw);
Object.keys(obj); // [ '__proto__', 'ok' ]
obj.polluted; // undefined
obj.hasOwnProperty('__proto__'); // true
So parsing is not the vulnerability. Copying is.
const merged = Object.assign({}, obj);
merged.polluted; // 'yes' — inherited from the attacker's object
({}).polluted; // undefined — the global prototype is still intact
Object.assign performs a [[Set]], and __proto__ is an accessor defined on Object.prototype, so the target's prototype is replaced. One object is compromised. A recursive merge is worse:
function merge(target, source) {
for (const k in source) {
if (source[k] && typeof source[k] === 'object') {
target[k] = target[k] || {};
merge(target[k], source[k]);
} else target[k] = source[k];
}
return target;
}
merge({}, obj);
({}).polluted; // 'yes' — every object in the process now inherits it
That is prototype pollution, the class behind CVE-2018-3721 and CVE-2019-10744 in lodash's merge (blocked by default since 4.17.11). In a Node service it escalates quietly: a polluted Object.prototype can inject options into child_process calls, template engines, or HTTP client defaults — RCE without a single line of attacker code running at parse time.
Object spread is the safe path, because it uses CreateDataProperty instead of [[Set]]:
const copy = {...obj};
copy.polluted; // undefined
Object.keys(copy); // [ '__proto__', 'ok' ]
structuredClone() keeps it as an own data property too. So "just use spread" is right for shallow copies and completely useless for a deep merge of nested defaults — which is what most config and webhook handlers actually do.
Fix it once, at the boundary, instead of in every merge helper:
const safe = JSON.parse(raw, (k, v) => k === '__proto__' ? undefined : v);
Or merge into Object.create(null) targets, or use a merge library that explicitly rejects __proto__, constructor, and prototype.
Practical detection: format the payload and look at the keys. I use CodeToolbox's JSON formatter for this — the tree view lists __proto__ like any other key, which is the whole point: JSON itself has no idea the name is special. It runs entirely in the browser, so you can paste a production response without shipping it to a third party.
Check your merge helper before the next webhook payload does it for you.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.