Prototype pollution

Write to __proto__ once and you've quietly added a property to every object in the program.

The idea

In JavaScript, almost every object shares one ancestor: Object.prototype. When you read obj.foo and obj has no own foo, the engine walks up the prototype chain to that shared parent. That sharing is normally invisible and useful.

Prototype pollution abuses it. A naive deep-merge that copies attacker-controlled keys can follow the key __proto__ straight to Object.prototype and write there. From that moment the planted property leaks into unrelated objects across the whole process — a single request can flip an auth check, crash a server, or worse, depending on which later sink reads it.

attacker payload (JSON) { "__proto__": { "isAdmin": true } } deepMerge walks each key key: — idle Object.prototype (shared by all objects) target own keys: {} [[Prototype]] -> user = {} own keys: {} [[Prototype]] ->
A request arrives carrying this JSON. The server is about to merge it into a config object.

How it works

The danger is not parsing the JSON — it is the recursive merge that follows the attacker's keys. This version has no key guard, so the key "__proto__" resolves to the shared parent instead of becoming an own property:

// VULNERABLE: copies any key, including __proto__
function deepMerge(target, source) {
  for (const key in source) {
    if (typeof source[key] === 'object' && source[key] !== null) {
      if (typeof target[key] !== 'object') target[key] = {};
      deepMerge(target[key], source[key]);   // recurse
    } else {
      target[key] = source[key];             // write
    }
  }
  return target;
}

const payload = JSON.parse('{"__proto__":{"isAdmin":true}}'); // parse is fine
deepMerge({}, payload);   // walks "__proto__" -> Object.prototype
({}).isAdmin;             // -> true  (leaked into a brand-new object)

The fix is to refuse the dangerous keys, or to merge into an object that has no shared prototype at all:

const BANNED = new Set(['__proto__', 'constructor', 'prototype']);

function safeMerge(target, source) {
  for (const key of Object.keys(source)) {
    if (BANNED.has(key)) continue;                 // 1) guard keys
    const v = source[key];
    if (v && typeof v === 'object') {
      if (!Object.hasOwn(target, key)) target[key] = {};
      safeMerge(target[key], v);
    } else {
      target[key] = v;
    }
  }
  return target;
}

// Stronger options, used together or alone:
const bag = Object.create(null);   // no prototype to pollute
const m   = new Map();             // keys never touch a prototype
Object.freeze(Object.prototype);   // process-wide backstop

Cost

Blast radius whole process
Trigger one request
Persistence process lifetime

One malicious request mutates global state shared by every object. Downstream impact runs from a quiet logic bypass (an auth flag flips), to denial of service (a planted property breaks unrelated code), to remote code execution (when a later sink reads the polluted value as a command or template). The cost is set by whichever sink reads the planted property next, not by the merge itself.

Watch out for

Worked example

Feed {"__proto__":{"isAdmin":true}} to the unsafe deepMerge. The loop hits the key "__proto__". Because the merge writes into target[key] with no guard, that lookup does not create an own property on target — it resolves to the shared Object.prototype, and isAdmin: true lands there.

Now, somewhere unrelated, a request handler creates a fresh user = {} and runs an authorization check:

const user = {};                // brand new, never touched the payload
if (user.isAdmin) grantAdmin();  // user has no own isAdmin...

The read user.isAdmin finds nothing on user, walks up the chain to the now-polluted Object.prototype, and returns true. The auth check passes for everyone, because every plain object inherits the same poisoned parent. No exploit code ran in the handler — the breach was planted earlier and simply waited for a sink to read it.

Check yourself

Besides __proto__, which key chain can also reach Object.prototype in an unguarded deep-set?

Is JSON.parse(payload) the vulnerable step here?