Prototype Pollution

ID

javascript.prototype_pollution

Severity

high

Remediation Complexity

trivial

Remediation Risk

low

Remediation Effort

low

Resource

Injection

Language

JavaScript

Tags

ASVS50:v1.5.3, CWE:1321, NIST.SP.800-53, OWASP:2025:A03

Description

A recursive merge writes target[key] with a key taken from an object the caller supplied, and never rejects proto, constructor or prototype.

In JavaScript every object shares Object.prototype. Writing through one of those three names does not set a property on the target — it sets one on the prototype, where every object in the process can then read it. A request body of {"__proto__": {"isAdmin": true}} merged into a settings object leaves ({}).isAdmin === true for the lifetime of the worker.

Rationale

The polluted property is visible to code that never touched the request: an authorization check reading user.isAdmin, a template engine reading an option it believes is its own default, a library branching on a flag it never set. Depending on what is polluted this is privilege escalation, denial of service, or — when a gadget exists on the load path — remote code execution.

JSON.parse produces proto as an own property, so hasOwnProperty does not help: the guard passes and the write still reaches the prototype.

Vulnerable code

function deepMerge(target, source) {
  for (const key of Object.keys(source)) {
    if (source[key] && typeof source[key] === 'object') {
      if (!target[key]) target[key] = {};
      deepMerge(target[key], source[key]);
    } else {
      target[key] = source[key];   (1)
    }
  }
  return target;
}

router.post('/merge', (req, res) => res.json(deepMerge({...defaults}, req.body)));
1 key comes from the request body, so it may be proto.

Remediation

Reject the three names before writing:

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

function deepMerge(target, source) {
  for (const key of Object.keys(source)) {
    if (FORBIDDEN.has(key)) continue;
    // ...
  }
  return target;
}

Alternatives that remove the class of bug rather than the instance:

  • Build the accumulator with Object.create(null), which has no prototype to pollute.

  • Use a Map instead of a plain object for caller-controlled keys.

  • Use a vetted merge (lodash.merge from 4.17.21, which carries the fix) rather than a local one.

  • Run with --disable-proto=throw on Node 12.17+ to make the write fail loudly.

© 2026 Xygeni. All rights reserved