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.