Prototype Pollution in JavaScript: Test and Defend
Understand prototype pollution in JavaScript and Node.js, trace pollution sources and gadgets, test safely, and harden applications effectively.
- AUTHOR
- Karol Rapacz / Pentester (OSCP, PNPT)
- PUBLISHED
- 5 May 2026
- READING TIME
- 15 min read
- TOPIC
- AppSec
Prototype pollution is a JavaScript-specific vulnerability in which user-controlled data modifies an object’s prototype. A polluted property may then be inherited by many apparently unrelated objects, changing request options, authorization decisions, sanitizer behaviour, or parameters passed to a sensitive server-side function.
In one paragraph: a complete attack usually has two phases. First, the attacker needs a pollution source—code that writes an attacker-controlled key into an object or merges data unsafely. Second, they need a gadget: application code that reads the inherited property and gives it security meaning. Filtering the string
__proto__alone is insufficient. Validate the complete schema, reject unknown keys, and never trust inherited properties in security decisions.
MITRE defines this class as CWE-1321: Improperly Controlled Modification of Object Prototype Attributes. MDN maintains a dedicated JavaScript prototype pollution guide that separates pollution sources from exploitation targets and documents practical defences.
Why one prototype can affect many objects
JavaScript uses prototype chains. If a property does not exist directly on an object, the engine looks for it on the object’s prototype and continues until it reaches Object.prototype. This is useful for method sharing but dangerous when an application assumes that a missing field means a false or safe default value.
const user = { name: "tester" };
console.log(Object.hasOwn(user, "isAdmin")); // false
console.log(user.isAdmin); // undefined
If vulnerable code allows Object.prototype.isAdmin = true, reading user.isAdmin returns the inherited value. This does not mean every affected application automatically grants administrator access. A gadget must exist: authorization logic that uses the value without checking that it belongs to that user object.
The critical distinction is:
| Element | Testing question | Example |
|---|---|---|
| source | can input modify a prototype? | deep merge, path parser, dynamic write |
| propagation | where is the value visible? | object, realm, process, or fleet |
| gadget | does code turn it into an effect? | authorization, fetch, template, child process |
| impact | what is actually achievable? | XSS, bypass, SSRF, DoS, sometimes RCE |
A confirmed source without a gadget is still an integrity weakness, but it should not automatically be reported as remote code execution. A strong assessment separates observed behaviour from hypothetical chains.
Common pollution sources
Dynamic property assignment
Code such as target[key] = value is dangerous when key comes from a request and is not allowlisted. The key may refer to __proto__ or build a constructor.prototype path. Helpers that support dotted paths, such as profile.preferences.theme, deserve particular attention.
Recursive object merging
A deep-merge function usually walks source keys and copies them into a target. If it treats special names like normal data, it may descend into a prototype object. The defect can live in application code, a utility library, a parameter parser, or a configuration layer.
Query-string and form parsers
Feature-rich parsers turn field names into nested structures. Verify that they reject special keys at every depth rather than only at the start of the string. Dependency updates matter, but vulnerability management does not replace review of custom data-transformation functions.
Combining JSON with configuration objects
JSON.parse() itself creates data; the problem often occurs later during assignment or merge. MDN documents how Object.assign() and spread syntax have different semantics around the legacy __proto__ setter. The useful conclusion is not “spread is always safe.” It is that each transformation must be understood and input validated before it runs.
Gadgets: where pollution becomes exploitation
Options objects are attractive targets. Applications pass partially defined configuration into fetch(), a template engine, renderer, HTTP client, or operating-system API. Missing values may then resolve through the prototype chain.
Frequent gadget classes include:
- authorization — code assumes that missing
isAdminmeans false; - HTTP requests — inherited
method,body,headers, or URL changes a connection; - DOM and templates — polluted configuration reaches
srcdoc, HTML, or another unsafe sink; - sanitizers — an inherited option changes the allowlist or processing mode;
- server processes — child-process or module-loader options become a gadget;
- denial of service — a polluted value has an unexpected type and triggers widespread exceptions.
In the OWASP Top 10 for web applications, prototype pollution may materialise as broken access control, injection, or a software-integrity failure. The technology label does not determine impact; the data flow does.
A safe testing method
Test only in an environment agreed with the owner. Polluting a global prototype in a long-lived Node.js process can affect other users’ requests. On production systems, prefer harmless unique markers, a minimal attempt count, and an immediate process replacement if the engineering team considers it necessary.
1. Inventory data transformations
During code review, look for dynamic writes, merge/clone/default helpers, path parsers, configuration deserialization, and places that copy a request into a domain object. SAST, DAST, and IAST complement manual review because a source and gadget may be far apart.
2. Confirm the source without business impact
Use a non-colliding property name and a neutral value. Check whether it appears as an inherited property on a new empty object or a specific target object. Do not set fields such as isAdmin, shell, method, or executable paths unless the scope explicitly permits that escalation.
3. Determine propagation scope
Does the effect remain inside one object, request, worker, process, or multiple instances? Serverless and clustered platforms can produce seemingly random results because the next request reaches a different process. Record instance identifiers, time, and state before and after a restart.
4. Find a gadget
Review reads of security-relevant properties. Focus on option objects with missing defaults, for...in loops, decisions such as if (obj.flag), and calls to sensitive APIs. Prove the flow with a neutral marker first. Only then—when agreed and safe—show a controlled effect on your own account or resource.
5. Clean up and reproduce
A prototype property may survive until the process restarts. Delete it explicitly, replace the instance, or restart the test service. The report should include the restoration procedure. Regression tests must verify both rejection of the special key and the absence of inherited-property use.
Fixing the pollution source
Validate the whole schema
The strongest input control is an allowlist of fields and types. In JSON Schema, additionalProperties: false rejects unexpected keys when the validator is correctly configured. Libraries such as Zod offer an equivalent strict-object approach. Validation must occur before merging, mapping, or domain logic.
Do not rely on a blacklist containing only __proto__. A constructor.prototype path and nested variants can bypass an overly simple filter. If dynamic keys are necessary, validate allowed characters and names, and reject special path segments at every depth.
Read own properties only
Object.hasOwn(obj, key) distinguishes object data from the prototype chain. Object.keys() followed by for...of is safer than for...in, which also visits enumerable inherited properties.
const isAdmin = Object.hasOwn(user, "isAdmin") && user.isAdmin === true;
Explicit defaults are also important. If isAdmin always exists and is strictly boolean, business logic does not have to interpret a missing field.
Use prototype-free structures
For dictionaries with dynamic keys, consider Map or Object.create(null). A null-prototype object does not inherit from Object.prototype. This does not replace value validation, but it removes an important class of name collisions.
Add runtime hardening
Node.js provides --disable-proto=delete and --disable-proto=throw, also described by MDN. This removes one entry point through Object.prototype.__proto__, but it does not block constructor.prototype. Treat it as defence in depth rather than a complete fix.
High-assurance environments may freeze built-ins or use SES/realm lockdown. Compatibility testing is essential because some libraries legitimately extend prototypes.
A safer design pattern
| Risky pattern | Better pattern |
|---|---|
| arbitrary JSON merged into config | strict DTO with field allowlist |
target[userKey] = value | Map or a validated key |
if (user.isAdmin) | own-property check + strict boolean |
for...in over input | Object.keys() + for...of |
| partial options without defaults | complete immutable options object |
| trust in a patched dependency alone | regression test for source and gadget |
Secure by Design means placing the trust boundary before the domain object is created. An HTTP controller should not feed raw request data into a deep merge. A mapping layer selects known fields, converts types, and creates a new object with a predictable shape.
Detection and monitoring
Logging full payloads may expose personal data. Prefer recording schema violations, the rejected field name, endpoint, and correlation identifier. Alerting on special key segments is useful, but their absence in logs does not prove safety.
CI tests should cover __proto__, constructor, prototype, nested paths, and unknown properties. After each test, assert that an empty object does not contain the marker and that authorization reads own properties only. OWASP ASVS 5.0 can map these controls to validation, integrity, and access-control requirements.
JavaScript security checklist
- Does every payload pass strict validation before any merge?
- Are unknown fields and special segments rejected at every depth?
- Do dynamic dictionaries use
Mapor a null prototype? - Do security decisions rely on
Object.hasOwn()and strict types? - Are option objects built with complete explicit defaults?
- Are parser and merge dependencies patched and regression-tested?
- Can an affected process be safely replaced after a pollution test?
- Does the test suite cover source, propagation, and gadget separately?
Prototype pollution demonstrates why a payload alone cannot determine severity. The assessor must understand language semantics, data flow, and the application’s actual gadgets. If you operate a JavaScript frontend or Node.js backend, book an application penetration test — we combine black-box assessment with targeted code review and identify the precise repair point.


