Prototype pollution: how it works, how to find it and how to fix it in Node.js

Prototype pollution is a JavaScript flaw where user input adds or changes properties on Object.prototype, so every object in the process inherits them. It usually comes from unsafe recursive merges. Alone it is often harmless, but with a gadget it leads to DOM XSS in browsers or remote code execution on servers. Fix it with safe merges, null-prototype objects or Map, schema validation and updated dependencies.

Prototype pollution is a JavaScript flaw where untrusted input adds or changes properties on a global object prototype, usually Object.prototype, so that every object in the process inherits them. CWE-1321 describes it as a product that receives input specifying attributes to initialize or update in an object, and fails to restrict changes to the object's prototype. It is also called prototype pollution in JavaScript and Node.js reports.

Testing for it needs written authorization from the system owner. The examples below use a fictional app and local code.

How does prototype pollution work?#

Prototype pollution works because JavaScript objects look up missing properties on their prototype. If an attacker can write to the prototype, every object that lacks that property reads the attacker's value. PortSwigger explains that this typically happens when a function recursively merges user-controlled data into an existing object without sanitizing keys, so a key like __proto__ writes to the prototype and not to the target object.

The flaw needs three parts, as PortSwigger describes them: a source (the input that injects properties), a sink (code that can run script or commands) and a gadget (a property that is read unsafely and can be inherited). Pollution alone is often harmless. The gadget makes it a vulnerability.

Illustrative pollution: Harbor Notes

This is a demonstration. No real customer system was tested. Harbor Notes is a fictional app and example.com is a reserved example domain.

The Harbor Notes API merges a settings object from the request body into the user's defaults with a hand-written recursive merge:

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

A tester on a local copy sends the settings body below, parsed with JSON.parse, which treats __proto__ as an ordinary own key:

{ "theme": "dark", "__proto__": { "probe": "polluted-by-tester" } }

After the merge, a fresh object that never had that field reads it:

const merged = merge({}, JSON.parse(body));
console.log({}.probe); // "polluted-by-tester": the shared prototype changed

The control is the same request without the __proto__ key, after a process restart, where {}.probe is undefined. A safe merge leaves {}.probe undefined for both requests.

The first merge call writes to the prototype because target["__proto__"] resolves to Object.prototype and the recursion then writes into it. The change lasts as long as the process runs, and it affects other users' requests in the same Node.js process.

What are the main types of prototype pollution?#

The two main types are client-side and server-side. They have different sources, different gadgets and different tests.

Type Typical source Typical gadget or impact How testers detect it
Client-side URL query or fragment, web messages DOM XSS through a script or HTML property Add ?__proto__[probe]=value and check Object.prototype.probe in the browser console
Server-side JSON body, query string parsed into nested objects Changed behavior, denial of service, in some apps remote code execution Non-destructive probes that change a harmless response property
Dependency A vulnerable library function such as a deep merge or parser Depends on the app Software composition analysis, version check, then a targeted test
Via constructor constructor.prototype instead of __proto__ Same as above, bypasses filters that only block __proto__ Test the alternate path on the same source

PortSwigger says client-side pollution commonly leads to DOM XSS, and server-side pollution can result in remote code execution. For the client-side impact, see DOM-based XSS. For a related server-side binding flaw, see mass assignment.

What are the real CVEs?#

Real prototype pollution CVEs sit in popular npm packages. This table gives each ID, its NVD publication date and the fixed version that NVD names. Dates are NVD publication dates checked on 9 November 2026.

CVE Package and function Published Fixed in
CVE-2019-10744 lodash defaultsDeep 26 Jul 2019 4.17.12
CVE-2020-7598 minimist (constructor or __proto__ payload) 11 Mar 2020 1.2.2
CVE-2020-8203 lodash zipObjectDeep 15 Jul 2020 4.17.20
CVE-2022-25878 protobufjs util.setProperty and related functions 27 May 2022 6.11.3
CVE-2023-36665 protobufjs, a different flaw with a user-controlled protobuf message 5 Jul 2023 7.2.5

NVD lists all five under CWE-1321. For lodash, the NVD text says "before 4.17.12" for the first and "before 4.17.20" for the second, so a project on 4.17.21 or later is past both fixes. Check your lock file for the version that is actually installed, because a nested dependency can pull an older copy.

From pollution to impact: what are gadgets?#

A gadget is a property that code reads without checking where it came from. PortSwigger's example is a library that uses config.transport_url || defaults.transport_url and then loads a script from that value. If the attacker pollutes transport_url, the library loads a script of the attacker's choice. An object defined with a null prototype inherits nothing, so it cannot carry a gadget.

On the server, a gadget is code that reads an option from an object that the developer did not fill in. PortSwigger documents server-side cases that lead to remote code execution through the options of process-spawning functions. Do not hunt for these on systems you do not own. In an authorized test, the evidence of a gadget is a harmless, reversible change, such as a property that alters a response field, and not a command run on the host.

How do penetration testers detect and prove prototype pollution?#

Testers prove it with a probe that changes a harmless property, then read the effect back and run a control. The method follows our methodology.

Client-side.

  1. Open the page in Chromium and add ?__proto__[probe]=1 to the URL, or ?constructor[prototype][probe]=1 for the constructor path.
  2. In the console, run ({}).probe. A value of "1" shows that the source works.
  3. Find a gadget by reading the page's client-side JavaScript bundles, or use DOM Invader in Burp's browser, which PortSwigger documents for this task.
  4. Label the result "Browser-observed" and include the URL, the console output and a screenshot.

Server-side.

  1. Use a test account and a test tenant. Send JSON with a __proto__ key to each endpoint that merges or stores nested objects.
  2. Look for a reflected property. If the response shows the injected property, the source works.
  3. If nothing reflects, PortSwigger lists three detection techniques that do not need reflection: status code override, JSON spaces override and charset override. Each changes a harmless response setting that the framework reads from a polluted property, and each can be reversed by restarting a test instance.
  4. Run controls: the same body without the __proto__ key, a body with a harmless unknown key, and the same request after restarting the instance.
  5. Read back from a second session to prove the effect reached other requests.

Because pollution lasts for the lifetime of the process, test on a staging copy where you can restart the instance, or agree the risk in writing. Never use probes on production that could break other users' requests. Report the controls that held, such as a merge path that ignores __proto__.

For dependency scans, run npm audit and your software composition analysis tool, then match each finding to the table above and check whether your code reaches the vulnerable function.

How do you prevent prototype pollution?#

Prevent it by not merging untrusted keys into plain objects, and by keeping the runtime and libraries up to date. OWASP's Prototype Pollution Prevention Cheat Sheet recommends Set or Map instead of object literals, Object.create(null) for objects without a prototype, and freezing built-in prototypes. It also recommends the --disable-proto=delete Node.js flag as defense in depth. It says that the flag is not a complete fix, because pollution is still possible through constructor.prototype.

Safe merge, with a key block:

const BLOCKED = new Set(["__proto__", "constructor", "prototype"]);

function safeMerge(target, source) {
  for (const key of Object.keys(source)) {   // own keys only
    if (BLOCKED.has(key)) continue;
    const value = source[key];
    if (value !== null && typeof value === "object" && !Array.isArray(value)) {
      if (!Object.hasOwn(target, key)) target[key] = Object.create(null);
      safeMerge(target[key], value);
    } else {
      target[key] = value;
    }
  }
  return target;
}

Null-prototype objects and Map for lookup tables:

const counts = Object.create(null);   // no inherited keys
counts[userKey] = (counts[userKey] ?? 0) + 1;

const cache = new Map();              // keys are data, not property names
cache.set(userKey, value);

Schema validation with an allow-list, using Zod, rejects unknown keys before any merge:

import { z } from "zod";
const Settings = z.object({ theme: z.enum(["light", "dark"]), lang: z.string().max(8) }).strict();
const clean = Settings.parse(JSON.parse(body));   // throws on a __proto__ or unknown key

Ajv works the same way with additionalProperties: false. A schema helps because it names the keys you accept, and it is the same control that stops mass assignment.

Runtime hardening:

// run once at startup, before any request handling
Object.freeze(Object.prototype);
node --disable-proto=delete app.js

The Node.js CLI documentation says --disable-proto=delete removes the Object.prototype.__proto__ property and throw makes access throw ERR_PROTO_ACCESS. OWASP warns that freezing is shallow and that some libraries change built-in prototypes, so test your app after you freeze.

Other fixes:

  • Update lodash to 4.17.21 or later, minimist to 1.2.2 or later and protobufjs to 7.2.5 or later (the highest fixed version in the table).
  • Avoid deep-merging request bodies into configuration objects. Copy the named fields you need.
  • Check that code that reads options uses Object.hasOwn for values it did not set itself.

Important

A key block that checks only __proto__ is not enough. The constructor.prototype path reaches the same prototype. Block both, or use null-prototype objects and a schema.

Prototype pollution testing checklist#

Run this on a test environment with written authorization.

  • Every endpoint that merges or stores nested JSON is tested with __proto__ and constructor.prototype keys.
  • Query strings parsed into nested objects are tested the same way.
  • Client-side URL sources are tested in a real browser with ?__proto__[probe]=1.
  • Server-side probes use harmless properties and a restartable test instance.
  • Controls run: no __proto__ key, an unknown harmless key, and the same request after a restart.
  • A read-back from a second session shows whether the change reached other requests.
  • Dependencies are checked against the CVE table above, and lock files are checked for nested older copies.
  • Merge functions in your own code are reviewed for unchecked keys.
  • A schema rejects unknown keys at the API boundary.
  • Object.freeze(Object.prototype) or --disable-proto=delete is tested in staging as a second layer.

When should you get a professional test?#

Get a professional test when your Node.js API merges client data into shared objects, when you ship a single-page app with URL-driven configuration, or when dependency alerts show prototype pollution CVEs and you need to know if they are reachable. A scanner can flag a version. It cannot tell you whether a gadget exists in your code. Our API penetration testing and web application penetration testing include safe pollution probes with controls and read-back evidence. Excession Labs agrees the scope with you in writing before testing starts. For a CORS review in the same scope, see CORS misconfiguration.

Need a pentest you can act on?

Tell us what you need tested and why. We reply to agree scope, and testing starts only after written authorization.

Questions

What is prototype pollution in simple terms?

In JavaScript, objects inherit properties from a prototype. Prototype pollution is when untrusted input adds a property to Object.prototype, the shared prototype, so every ordinary object seems to have it. PortSwigger says it typically happens when a function recursively merges user-controlled data into an object without sanitizing the keys.

Is prototype pollution only a JavaScript problem?

Yes, the class is specific to JavaScript and runtimes such as Node.js, because it depends on prototype inheritance. CWE-1321 describes it in general terms as a failure to restrict changes to an object's prototype attributes. The impact depends on the code that reads the polluted property.

Does npm audit find prototype pollution?

It finds known vulnerable versions of dependencies, such as lodash before 4.17.12 for CVE-2019-10744. It does not find pollution in your own merge code, or in a library version with no advisory. Use it for dependencies and a code review or test for your own merge functions.

Does Node's --disable-proto flag fix prototype pollution?

No. OWASP calls it a defense in depth measure and says pollution is still possible through constructor.prototype. The flag removes or blocks the proto accessor. It is a second layer. Safe merge code and validated input are the fix.

Can prototype pollution lead to remote code execution?

Yes, on the server side, when a polluted property reaches a sink such as the options of a process-spawning call or a template engine. PortSwigger documents server-side cases that lead to remote code execution. The result depends on a gadget in your code or dependencies, so each app needs its own test.

Sources

  1. PortSwigger Web Security Academy: Prototype pollution (checked 9 November 2026)
  2. PortSwigger Web Security Academy: Server-side prototype pollution (checked 9 November 2026)
  3. OWASP Prototype Pollution Prevention Cheat Sheet (checked 9 November 2026)
  4. CWE-1321: Improperly Controlled Modification of Object Prototype Attributes (checked 9 November 2026)
  5. Node.js documentation: --disable-proto (checked 9 November 2026)
  6. NVD: CVE-2019-10744 (lodash defaultsDeep) (checked 9 November 2026)
  7. NVD: CVE-2020-8203 (lodash zipObjectDeep) (checked 9 November 2026)
  8. NVD: CVE-2020-7598 (minimist) (checked 9 November 2026)
  9. NVD: CVE-2022-25878 (protobufjs) (checked 9 November 2026)
  10. NVD: CVE-2023-36665 (protobufjs) (checked 9 November 2026)