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.



