Understanding and Preventing Prototype Pollution in JavaScript
AI generated
OWASP
0x00
Prototype Pollution · JavaScript Security
Preventing Prototype Pollution in JavaScript
How an unsafe merge function lets attackers manipulate the global Object.prototype and thereby compromise the entire application

Prototype pollution is a JavaScript-specific vulnerability class in which an attacker, via an unsafe object merge, writes properties onto `Object.prototype` itself instead of onto the actually intended target object, causing these manipulated properties to subsequently appear on every newly created object across the entire application, often with serious consequences up to remote code execution. This vulnerability affects both client-side browser code and server-side Node.js code and is especially tricky because the triggering merge code looks completely harmless at first glance.

16 min read Prototype Pollution JavaScript Security

1. A brief detour: how JavaScript prototypes work

In JavaScript, practically every object has an internal link to a prototype object from which it inherits properties and methods when these aren't defined directly on the object itself. The prototype of an ordinary object created with `{}` or `new Object()` is `Object.prototype`, and since practically every object in a JavaScript application ends up in this prototype chain eventually, any change to `Object.prototype` itself potentially affects every existing AND every future object across the entire application, regardless of which module or library later uses that object.

This global reach makes `Object.prototype` an exceptionally attractive attack target: if an attacker manages to write a property onto `Object.prototype` instead of onto an ordinary, locally scoped object, this manipulation potentially affects every code path in the application that at some point creates a plain object and accesses a property with the same name, even if that code path lives in a completely different, seemingly unrelated part of the application.

2. The attack vector: __proto__ injection via merge functions

The classic attack path runs through a recursive merge or extend function, as exists in countless utility libraries and hand-written code to deeply combine two objects, say to combine user configuration options with default values. If this function processes the source object's property names unfiltered, an attacker can inject a JSON object with the key `__proto__` (say, as the body of an API request) that the merge function interprets as an ordinary property name, but that the JavaScript engine treats as special access to the target object's prototype.

Since `__proto__` is a special accessor in most JavaScript engines that directly accesses an object's internal prototype instead of setting an ordinary property, `merge(targetObject, JSON.parse(attackerInput))` with a crafted input like `{"__proto__": {"isAdmin": true}}` causes the property `isAdmin` to land not on `targetObject`, but on `Object.prototype` itself, after which every newly created, plain object across the entire application is affected.

3. A vulnerable merge example and its impact

The following example shows a naive, recursive merge function, of the kind that actually ran in production in many projects (and also in older versions of well-known npm packages like lodash before corresponding patches) before this vulnerability class became widely known, with the property name never checked against `__proto__`, `constructor`, or `prototype`.


// VULNERABLE: no check for dangerous key names
function merge(target, source) {
  for (const key in source) {
    if (typeof source[key] === 'object' && source[key] !== null) {
      if (typeof target[key] !== 'object') target[key] = {};
      merge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

// Attacker input as JSON body of an API request:
// { "__proto__": { "isAdmin": true } }
merge({}, JSON.parse(attackerInput));

// From now on EVERY newly created plain object has
// the property isAdmin === true, application-wide:
console.log({}.isAdmin); // true

4. From logic bugs to remote code execution

The immediate consequences of a successful prototype pollution range from seemingly harmless logic bugs (say, an authorization check that mistakenly treats `isAdmin` as true on every object) to considerably more serious attacks. It becomes especially critical when a property injected via prototype pollution is interpreted by a downstream library as a configuration option that internally calls `eval()`, `child_process.exec()`, or a comparably dangerous function, turning a seemingly harmless object pollution into actual remote code execution.

Several real, documented CVEs in well-known npm packages (among others in template engines and configuration libraries) showed exactly this escalation chain: prototype pollution via an unchecked merge function, followed by exploiting a downstream, ostensibly trustworthy library that translated the manipulated prototype property into code execution without checking it.

5. Safe merge implementation with explicit key checking

The most direct fix is to explicitly check every recursive merge or extend function against the three dangerous key names `__proto__`, `constructor`, and `prototype` and consistently skip these keys before any assignment happens at all, instead of relying on an external library whose security posture you don't know.


// SAFE: dangerous keys are explicitly blocked
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);

function safeMerge(target, source) {
  for (const key of Object.keys(source)) {
    if (DANGEROUS_KEYS.has(key)) continue;
    if (typeof source[key] === 'object' && source[key] !== null) {
      if (typeof target[key] !== 'object') target[key] = {};
      safeMerge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

6. Object.freeze(Object.prototype) as an additional defense layer

As an additional, defensive measure, `Object.prototype` can be frozen at application startup with `Object.freeze(Object.prototype)`, causing every later attempt to write a new property onto `Object.prototype` to fail in strict mode (throwing a `TypeError`) instead of silently succeeding. This approach acts as a safety net that still catches a prototype pollution vulnerability even if it exists in an unexpected, not-yet-hardened place in the code.

This measure is not a replacement for a safe merge implementation, though, but an additional layer, since it can cause compatibility issues in a few rare, legitimate use cases (say, some polyfill libraries that deliberately extend `Object.prototype`) and should therefore be carefully tested against your own library dependencies before being enabled in production.

7. Map instead of a plain object for user-controlled keys

Wherever application code uses an object as a lookup table with user-controlled keys, say to store configuration values keyed by a name coming from the request, a native `Map` object is the structurally safer replacement for a plain `{}` object, since `Map` instances don't have a prototype chain in the same sense and keys like `__proto__` are treated there as perfectly ordinary, harmless strings, with no special meaning at all.

This architectural shift from object to `Map` fixes the problem at its root instead of merely catching it via a key blocklist, and is therefore the most robust long-term solution wherever new code is written for user-controlled lookup tables.

8. Auditing dependencies for known prototype pollution CVEs

Since many prototype pollution vulnerabilities have historically lived in widely used npm packages themselves, not just in hand-written code, a regular check of your own dependencies with `npm audit` or a comparable tool belongs to basic hardening, since these tools match known CVEs, including numerous documented prototype pollution cases, against installed package versions and flag outdated, vulnerable versions.

This check is especially relevant for packages that merge, parse, or clone objects from external, user-controlled data, say query string parsers, JSON schema validators, or configuration loaders, since exactly this category of libraries has historically been most frequently affected by prototype pollution CVEs.

9. Deliberately testing for and detecting prototype pollution

A deliberate test sends purposely crafted payloads with the keys `__proto__`, `constructor.prototype`, and related variants to every endpoint accepting user-controlled JSON data, then checks whether `Object.prototype` was actually altered, say by reading a test key on a freshly created empty object after the request. Automated tools and specialized prototype pollution scanners in common security testing suites handle this check systematically across the entire API surface, instead of trying every endpoint by hand one at a time.

It's also worth running a static code scan that automatically identifies and flags recursive merge, extend, and clone functions in your own code, so each of these functions can be deliberately checked against the protective measures described in this article, instead of relying on a random discovery during a penetration test.

Measure Protective effect Effort
Key checking in merge functions Blocks the attack vector directly at the source Low, one-time implementation
Object.freeze(Object.prototype) Safety net against unknown gaps Low, check compatibility beforehand
Map instead of object Fixes the problem structurally Medium, some refactoring needed
npm audit / dependency scanning Uncovers vulnerabilities in dependencies Low, can be automated in CI

Mironsoft

Security audits, OWASP-compliant hardening, and secure architecture

Applications that actually hold up against a real attack attempt?

We review existing applications for classic OWASP vulnerabilities, insecure authentication, and missing input validation, then build an architecture that structurally reduces attack surface instead of just patching individual symptoms.

Security Audit

Systematically checking OWASP Top 10, auth flows, and input validation for vulnerabilities.

Secure Architecture

Building rate limiting, encryption, and access controls correctly from the ground up.

Incident Readiness

Establishing logging, monitoring, and response processes for when things go wrong.

10. Summary

Prototype Pollution: The Essentials at a Glance

Core idea

An unsafe merge function can accept __proto__ as a key and thereby alter Object.prototype itself.

Reach

A change to Object.prototype affects every newly created, plain object across the entire application.

Best fix

Explicitly block dangerous keys (__proto__, constructor, prototype) in every merge function.

Escalation

Can escalate up to remote code execution if a downstream library translates the manipulated property into code execution.

11. FAQ: Prototype Pollution: The Essentials at a Glance

1Does prototype pollution only affect Node.js or the browser too?
Both, anywhere an unsafe merge function combines objects from user-controlled data.
2Is JSON.parse alone enough protection?
No, JSON.parse doesn't create a true __proto__ access in the parsed object itself, the problem only arises in the subsequent merge function.
3Is Object.freeze(Object.prototype) complete protection?
No, it's an additional safety net, but doesn't replace a safe merge implementation.
4Why is Map safer than a plain object?
Map doesn't have a prototype chain in the same sense, keys like __proto__ are treated there as ordinary, harmless strings.
5Can lodash.merge cause prototype pollution?
Older versions were affected and got patched, current versions should be used and regularly checked via npm audit.
6How do I know if my application is vulnerable?
Examine all recursive merge/extend/clone functions in your own code and dependencies for missing key checks.
7Does prototype pollution always lead to remote code execution?
No, only if a downstream component translates the manipulated property into a dangerous operation like eval().
8Should I block constructor and prototype too, not just __proto__?
Yes, all three keys enable related attacks and should be blocked together consistently.
9Does TypeScript help against prototype pollution?
Only to a limited extent, since TypeScript types no longer exist at runtime and the vulnerability is a runtime problem.
10Are there ready-made, safe merge libraries?
Yes, modern versions of established libraries have built in explicit prototype pollution protections, check the current version and changelog.