Dev.to Security 🔐 Cybersecurity 👁 0 📖 2 min read

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.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.