Skip to content
Prototype Pollution

Prototype Pollution: How One JSON Key Changes Every Object in Your App

A single __proto__ key in a request can change the defaults of every object in a Node.js process or a browser page. Here is how unsafe merges cause prototype pollution, which gadgets turn it into real impact, and how to close it.

Part 1 of 1From the Prototype Pollution series

At a glance

CWE-1321
Interpreter
JavaScript object model in Node.js or the browser
Required condition
Untrusted keys such as __proto__ or constructor.prototype reach a recursive merge, clone or path assignment.
Potential impact
Process-wide property injection enabling logic bypass, denial of service, or code execution through gadgets.
Primary defense
Schema-validate input, skip prototype keys, and keep user-keyed data in Map or null-prototype objects.

Safe practice: use benign proof only, in a disposable local lab or on a system you are explicitly authorized to test.

On this page

Prototype pollution is a JavaScript-specific flaw. Almost every JavaScript object inherits from one shared object, Object.prototype. When an application copies attacker-controlled keys into an object unfiltered, a key named __proto__ or the path constructor.prototype does not create a new property on that object. It reaches the shared prototype instead, and the injected property then appears on every plain object in the Node.js process or browser page that does not define it itself.

Pollution alone changes nothing visible. The damage comes from gadgets: code that reads an optional property and falls back to the polluted value. Depending on the gadget, that means a bypassed authorization check, a crashed service or code execution. OWASP Top 10:2025 lists the parent weakness, CWE-915, under A08 Software or Data Integrity Failures.

The mental model: lookups walk the prototype chain

When code reads options.isAdmin, the engine checks the object's own properties first and then walks up its prototype chain. For a plain object, the next stop is Object.prototype.

Expression Without pollution After Object.prototype.isAdmin = true
({}).isAdmin undefined true
({ isAdmin: false }).isAdmin false false
Object.hasOwn({}, 'isAdmin') false false
Object.create(null).isAdmin undefined undefined

Own properties always win, and null-prototype objects never see the injected value. That table is also the outline of the fix.

How it happens

The usual source is a recursive merge, clone or set-by-path helper fed with parsed JSON, a nested query string or a form body. Here is a typical hand-written merge:

function merge(target, source) {
  for (const key in source) {
    const value = source[key];
    if (typeof value === 'object' && value !== null) {
      if (typeof target[key] !== 'object' || target[key] === null) {
        target[key] = {};
      }
      merge(target[key], value);
    } else {
      target[key] = value;
    }
  }
  return target;
}

app.post('/api/preferences', express.json(), (req, res) => {
  res.json(merge({}, req.body));
});

JSON.parse turns {"__proto__":{"isAdmin":true}} into an object with an ordinary own key named __proto__. Parsing is harmless; the merge is not. When the loop reaches that key, target['__proto__'] on a normal object returns Object.prototype itself, and the recursive call writes isAdmin there. The path constructor.prototype reaches the same object by another route: a plain object's constructor is Object, and Object.prototype is one property away. Helpers that descend into functions, or that assign by dotted path such as set(obj, 'constructor.prototype.isAdmin', true), follow it.

Browser code has the same problem with different sources. A hand-rolled parser that expands ?__proto__[widgetUrl]=... or a URL fragment into nested objects pollutes the page, and a gadget such as script.src = config.widgetUrl || '/static/widget.js' turns the inherited value into DOM-based XSS. Old releases of popular front-end libraries shipped deep merges with this flaw. SelfSec's Prototype Pollution module tests the server side; on the client, its JS Library Fingerprint module flags library versions with known prototype pollution CVEs, covered in vulnerable components.

A concrete authorized test

Pollution is process-wide and lasts until the process restarts, so a test must use a property nothing depends on, observe it on a separate request and put it back. Express offers a convenient gadget: before 4.18.0 it read its json spaces setting through the prototype chain, and that setting controls how res.json indents output.

First, record a baseline from an endpoint that returns JSON:

GET /api/status HTTP/1.1
Host: app.example
{"status":"ok"}

Then send the pollution payload to the endpoint under test:

POST /api/preferences HTTP/1.1
Host: app.example
Content-Type: application/json

{"__proto__":{"json spaces":"ssPp"}}

Now repeat the clean baseline request. If the merge polluted Object.prototype, Express uses the marker as its indentation string:

{
ssPp"status": "ok"
}

The marker appears on a request that never carried it, which reflection cannot explain. Finally, set the property to an empty string, confirm compact output returns, and note that only a restart removes it:

{"__proto__":{"json spaces":""}}

If __proto__ is filtered, repeat with {"constructor":{"prototype":{"json spaces":"ssPp"}}}. Query strings and form bodies that a parser expands into nested objects take the bracket forms __proto__[json%20spaces]=ssPp and constructor[prototype][json%20spaces]=ssPp. A second harmless gadget is status: error responses built with the http-errors module take the status from the error object, inherited values included, so a code in the 400–599 range such as 599 can surface on a failing clean request.

SelfSec runs this baseline, pollute, observe and revert sequence and shares its limit: Express 4.18.0 and later ignore Object.prototype values in app settings, so a current stack hides a vulnerable merge from this gadget. No signal is not proof of safety.

What an attacker gains

The impact depends on the gadgets the application and its dependencies contain:

  • Authorization and logic bypass: checks such as if (user.isAdmin) or if (options.skipValidation) on objects that lack the property read the polluted value.
  • Denial of service: replacing a common method such as toString with a string makes routine string conversion throw across the whole process until it restarts.
  • Code execution: template-engine compile options and process-spawning options read from plain objects have been turned into server-side template injection and remote code execution in published research.
  • Client-side script injection: polluted configuration that reaches a script URL, an HTML sink or eval runs attacker-controlled JavaScript in the victim's browser.

The fix: validate keys before anything merges them

The strongest fix is not to deep-merge untrusted input at all. Validate request bodies against a schema that lists the allowed properties, then copy only those. In Node.js with Ajv:

const express = require('express');
const Ajv = require('ajv');

const ajv = new Ajv();
const validatePreferences = ajv.compile({
  type: 'object',
  properties: {
    theme: { enum: ['light', 'dark'] },
    pageSize: { type: 'integer', minimum: 10, maximum: 100 },
  },
  additionalProperties: false,
});

const DEFAULT_PREFERENCES = Object.freeze({ theme: 'light', pageSize: 20 });
const app = express();

app.post('/api/preferences', express.json(), (req, res) => {
  if (!validatePreferences(req.body)) {
    res.status(400).json({ error: 'invalid preferences' });
    return;
  }
  res.json({ ...DEFAULT_PREFERENCES, ...req.body });
});

With additionalProperties: false, a body carrying __proto__ or constructor fails validation. Object spread defines own data properties, so even a stray __proto__ key could not change a prototype.

When a generic merge is genuinely needed, for example to layer configuration files, walk own keys only and skip the three prototype keys:

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

function isPlainObject(value) {
  if (value === null || typeof value !== 'object') return false;
  const proto = Object.getPrototypeOf(value);
  return proto === Object.prototype || proto === null;
}

function safeMerge(target, source) {
  for (const key of Object.keys(source)) {
    if (BLOCKED_KEYS.has(key)) continue;
    const value = source[key];
    if (isPlainObject(value)) {
      const current = Object.hasOwn(target, key) && isPlainObject(target[key]) ? target[key] : {};
      target[key] = safeMerge(current, value);
    } else {
      target[key] = value;
    }
  }
  return target;
}

In the browser, keep user-keyed data in a Map and accept only the parameter names the page uses:

const ALLOWED_PARAMS = new Set(['q', 'page', 'sort']);

function readParams(search) {
  const params = new Map();
  for (const [key, value] of new URLSearchParams(search)) {
    if (ALLOWED_PARAMS.has(key)) params.set(key, value);
  }
  return params;
}

A Map stores entries apart from its prototype chain, so a key named __proto__ is just another string.

Finally, harden the runtime as defense in depth. Node.js can make the __proto__ accessor throw, and freezing Object.prototype after startup blocks later writes:

node --disable-proto=throw server.js
Object.freeze(Object.prototype);

Neither replaces validation: --disable-proto ignores constructor.prototype, and freezing one prototype leaves class prototypes writable. Test freezing first; some libraries patch built-ins.

Fixes that do not hold

  • Blocking only __proto__: constructor.prototype reaches the same object, and dotted or bracket paths can rebuild either.
  • Filtering top-level keys only: the dangerous key can sit at any depth of a nested body.
  • Validating values instead of keys: the attack lives in the property name, not the data.
  • Blaming JSON.parse: parsing creates an inert own key; the merge, clone or setter that follows does the damage.
  • Relying on a WAF signature: JSON escapes such as \u005f spell the same key differently, and the merge stays vulnerable.
  • Treating a quiet scan as proof: detection depends on observable gadgets, and current frameworks remove some of them.

A developer review checklist

  1. Find every deep merge, clone, extend, defaults or set-by-path call that receives request, URL, message or stored data.
  2. Validate bodies against an allowlist schema with no additional properties before they reach any merge.
  3. In custom helpers, iterate own keys, skip __proto__, constructor and prototype, and never descend into inherited values.
  4. Store user-keyed dictionaries in a Map or a null-prototype object.
  5. Read security-relevant flags with Object.hasOwn or from objects that have no prototype.
  6. Keep merge, query-string and framework dependencies current, and add --disable-proto or frozen prototypes as defense in depth.

References

Module: Prototype Pollution. How it probes: Records a clean baseline per endpoint and channel, then sends __proto__ and constructor.prototype payloads as a nested JSON body and bracket-notation form fields where the endpoint takes a body, and as bracket-notation query parameters everywhere, aimed at two gadgets: Express's json spaces setting carrying a random marker on endpoints that answer with JSON, and an inherited status property set to 599. How it confirms: The effect must show on a separate, unpolluted follow-up request, as the marker used for JSON indentation or as the 599 status, neither present in the baseline, and must vanish once the property is reset to a benign value; reflection alone never counts, and the clean request that showed the effect stays with the finding as evidence. A finding moves from Possible to Confirmed only after this check. Reported as CWE-1321 · SARIF 2.1.0.
How SelfSec proves this one
Module
Prototype Pollution
How it probes
Records a clean baseline per endpoint and channel, then sends __proto__ and constructor.prototype payloads as a nested JSON body and bracket-notation form fields where the endpoint takes a body, and as bracket-notation query parameters everywhere, aimed at two gadgets: Express's json spaces setting carrying a random marker on endpoints that answer with JSON, and an inherited status property set to 599.
How it confirms
The effect must show on a separate, unpolluted follow-up request, as the marker used for JSON indentation or as the 599 status, neither present in the baseline, and must vanish once the property is reset to a benign value; reflection alone never counts, and the clean request that showed the effect stays with the finding as evidence.
Reported as CWE-1321 · SARIF 2.1.0

A reflected response alone is not enough to promote a Prototype Pollution finding. The classical engine has to confirm the behavior before it reaches the report. See the detection engine

SelfSec scanner

Find Prototype Pollution in your own app

Reading about the class is one thing. SelfSec tests for it against an application you are authorized to assess, and only calls it real when independent evidence agrees.

Find it Confirmed, not guessed 21 vulnerability modules, with the classical engine owning the verdict. Prove it Evidence you can re-read Every finding keeps the raw request, the response and the baseline it was measured against.
Join the launch list