Prototype Pollution in Recursive Object.assign

Prototype pollution is a critical JavaScript vulnerability where an attacker manipulates the prototype of base objects, potentially leading to property injection, application crashes, or remote code execution. This article explores how custom recursive implementations of Object.assign—commonly known as deep merge functions—can inadvertently expose Node.js and browser applications to prototype pollution, examine how attacks occur, and provide actionable techniques to secure your code.

Understanding Prototype Pollution

In JavaScript, objects inherit properties and methods from prototypes via the prototype chain. The root of this chain is usually Object.prototype. When an application modifies Object.prototype, the new or altered properties become accessible on every object in the runtime environment.

Prototype pollution occurs when untrusted input is processed without proper sanitization, allowing an attacker to inject properties into Object.prototype using special accessor keys such as __proto__, constructor, or prototype.

Why Recursive Object.assign Is Vulnerable

Native Object.assign() performs a shallow copy and does not traverse nested objects. To overcome this, developers frequently implement recursive wrappers around Object.assign or write custom deep merge utilities.

A vulnerable recursive merge function typically follows this pattern: 1. It iterates over the keys of a source object. 2. If a key’s value is another object, it recursively merges that object into the target. 3. If the key’s value is a primitive, it copies the value directly.

When such a function encounters a payload containing the key __proto__, it accesses the target object’s prototype directly. Instead of assigning a property to the intended local object, the recursive function sets properties on Object.prototype, polluting every object in the application.

// Vulnerable recursive merge implementation
function deepMerge(target, source) {
  for (let key in source) {
    if (source[key] instanceof Object && key in target) {
      deepMerge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

// Malicious payload
const maliciousPayload = JSON.parse('{"__proto__": {"isAdmin": true}}');

// Merging the payload
deepMerge({}, maliciousPayload);

// The vulnerability manifests globally
const newUser = {};
console.log(newUser.isAdmin); // true

Potential Impact of Exploitation

Mitigation Strategies

To prevent prototype pollution in recursive merge operations:

  1. Filter Dangerous Keys: Explicitly block sensitive properties such as __proto__, constructor, and prototype before performing any assignment or recursive call.

    function safeDeepMerge(target, source) {
      for (let key of Object.keys(source)) {
        if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
          continue;
        }
        if (typeof source[key] === 'object' && source[key] !== null) {
          if (!target[key]) target[key] = {};
          safeDeepMerge(target[key], source[key]);
        } else {
          target[key] = source[key];
        }
      }
      return target;
    }
  2. Use Prototype-less Objects: When storing key-value pairs from user input, create objects with no prototype using Object.create(null) or use the Map data structure.

  3. Freeze the Prototype: Prevent modifications to the root prototype by calling Object.freeze(Object.prototype) at application startup. Note that this may cause compatibility issues with legacy libraries that modify prototypes legitimately.

  4. Use Validated Libraries: Avoid writing custom deep merge algorithms. Rely on well-maintained libraries like modern versions of lodash.merge that include built-in protection against prototype pollution.