Prototype pollution
Learn how unsafe JavaScript merges, JSON, query strings, and path helpers can create inherited defaults, then block prototype pollution safely.
- 01Trace the pollution pathShow how
__proto__andconstructor.prototypeturn a data key into a prototype write. - 02Harden merges and path helpersSkip dangerous keys before target lookup and use own-property checks when reading decisions.
- 03Choose safer containersCompare plain objects, null-prototype dictionaries, Map, frozen prototypes, validation, and Node flags.
Prototype pollution in plain words
Prototype pollution is a data-to-prototype bug. Untrusted input contains a key such as __proto__, constructor, or prototype. A merge, parser, or path setter treats that key like normal data and writes to a shared prototype. Later, ordinary property lookup makes fresh objects appear to have attacker-chosen defaults.
In one sentence: merging untrusted objects can change every object that inherits from the polluted prototype. This lesson builds on the prototype chain, Object.create, built-in prototypes, freezing and sealing, JSON, classic utilities, Map & Set, property descriptors, and XSS.
Prototype pollution happens when data controls a property write that reaches a prototype object, so inherited lookup later supplies values that were never intentionally set on each object.
Imagine a building where every apartment door has its own notes, but everyone also reads a shared noticeboard. If an attacker can pin “all residents are staff” on the noticeboard, apartments without their own staff note appear to inherit it.
- In real life: A private note on one door
- In JavaScript: An own property on one object
- In real life: The building noticeboard
- In JavaScript: A shared prototype such as
Object.prototype - In real life: A fake notice saying everyone is staff
- In JavaScript: A polluted inherited default like
isAdmin: true - In real life: Checking the tenant's own ID
- In JavaScript:
Object.hasOwn(user, "isAdmin")
Where the analogy stops: Real people can question a suspicious notice. JavaScript property lookup follows the chain mechanically unless your code asks for own properties or uses safer containers.
Runnable snippets that touch Object.prototype do it inside the lesson editor's fresh iframe or the test helper's fresh VM context, and they delete the demo property in finally. The visible step-throughs use a local sandbox prototype so the page's real Object.prototype is never changed.
Why one prototype reaches many objects
FOUNDATIONProperty lookup checks an object first. If the property is missing, lookup walks the object's [[Prototype]] link. Most ordinary objects eventually reach Object.prototype. Add a property there and ({}).isAdmin can read it even though the fresh object owns nothing.
The legacy __proto__ accessor normally lives on Object.prototype. Reading obj["__proto__"] can return the object's prototype. Assigning obj["__proto__"] = other can change that one object's prototype. A deep merge is more dangerous: it may read the prototype and then recursively write into it.
const defaults = { isAdmin: true };const requestConfig = Object.create(defaults); console.log(requestConfig.isAdmin);console.log(Object.hasOwn(requestConfig, "isAdmin"));Line 1 creates an object that inherits isAdmin. Line 4 uses Object.hasOwn to reject that inherited flag. Authorization, feature flags, and configuration fallbacks should make that distinction explicit.
JSON is safe until assignment
STEP THROUGHJSON.parse does not call the __proto__ accessor. It creates an own data property whose name is literally "__proto__". That parsed object is not polluted. The danger starts later when a naive merge copies that own property with assignment or recursively merges into target["__proto__"].
__proto__ propertyfunction isObjectLike(value) { return value !== null && (typeof value === "object" || typeof value === "function");}function unsafeMerge(target, source) { for (const key of Object.keys(source)) { const value = source[key]; if (isObjectLike(value) && isObjectLike(target[key])) { unsafeMerge(target[key], value); } else { target[key] = value; } } return target;} const parsed = JSON.parse('{"__proto__":{"isAdmin":true}}');console.log(Object.hasOwn(parsed, "__proto__"));console.log(Object.getPrototypeOf(parsed) === Object.prototype);console.log(({}).isAdmin);try { unsafeMerge({}, parsed); console.log(({}).isAdmin);} finally { delete Object.prototype.isAdmin;}The first two logs prove the exact behavior: the parsed value owns the __proto__ property, and its real prototype is still Object.prototype. The third log is undefined. Only after unsafeMerge does a fresh object read true through the polluted prototype. The finally block cleans up the demo property.
Step through how a naive deep merge turns an own __proto__ key into an inherited default.
script
function isObjectLike(value) { return value !== null && (typeof value === "object" || typeof value === "function");}function unsafeMerge(target, source) { for (const key of Object.keys(source)) { const value = source[key]; if (isObjectLike(value) && isObjectLike(target[key])) { unsafeMerge(target[key], value); } else { target[key] = value; } } return target;}const target = Object.create(sandboxPrototype);const payload = JSON.parse('{"__proto__":{"isAdmin":true}}');unsafeMerge(target, payload);const fresh = Object.create(sandboxPrototype);console.log(fresh.isAdmin);function isObjectLike(value) { return value !== null && (typeof value === "object" || typeof value === "function");}function unsafeMerge(target, source) { for (const key of Object.keys(source)) { const value = source[key]; if (isObjectLike(value) && isObjectLike(target[key])) { unsafeMerge(target[key], value); } else { target[key] = value; } } return target;}const sandboxPrototype = {};const target = Object.create(sandboxPrototype);const payload = JSON.parse('{"__proto__":{"isAdmin":true}}');unsafeMerge(target, payload);const fresh = Object.create(sandboxPrototype);console.log(fresh.isAdmin);not run__proto__?not runnot runChoose a merge and run it inside a separate iframe realm. The lesson page's own Object.prototype is not touched.
Unsafe deep merges and path setters
ATTACK SHAPESThe vulnerable pattern is small: obj[key] = value or merge(target[key], value) while key comes from input. A single __proto__ key is one route. The nested constructor.prototype route is another: a naive merge may follow inherited constructor to Object, then prototype to Object.prototype.
function setByPath(target, path, value) { const parts = path.split("."); let current = target; for (const part of parts.slice(0, -1)) { if (current[part] == null) current[part] = {}; current = current[part]; } current[parts.at(-1)] = value; return target;} try { setByPath({}, "a.__proto__.polluted", true); console.log(({}).polluted);} finally { delete Object.prototype.polluted;}Set-by-path helpers, form parsers, and query-string parsers often turn text into segments. A URL shape such as ?__proto__[isAdmin]=1 or a path such as a.__proto__.polluted is dangerous if the parser builds objects by assignment without filtering each segment.
function isObjectLike(value) { return value !== null && (typeof value === "object" || typeof value === "function");}function unsafeMerge(target, source) { for (const key of Object.keys(source)) { const value = source[key]; if (isObjectLike(value) && isObjectLike(target[key])) { unsafeMerge(target[key], value); } else { target[key] = value; } }} try { unsafeMerge({}, JSON.parse('{"constructor":{"prototype":{"polluted":"yes"}}}')); console.log(({}).polluted);} finally { delete Object.prototype.polluted;}Blocking only __proto__ is incomplete. A safe merge treats __proto__, constructor, and prototype as dangerous at every depth, before it reads from the target.
Block dangerous keys early
STEP THROUGHA hardened merge does three boring things. First, it rejects dangerous keys and path segments before any target lookup. Second, it treats untrusted input as schema-validated data, not as arbitrary object shape. Third, it reads security decisions with own-property checks.
Step through the same payload being blocked by a key guard and own-property reads.
script
function isPlainObject(value) { return value !== null && typeof value === "object" && !Array.isArray(value);}function safeMerge(target, source) { for (const key of Object.keys(source)) { if (dangerousKeys.has(key)) continue; const value = source[key]; if (isPlainObject(value)) { const current = Object.hasOwn(target, key) ? target[key] : undefined; target[key] = safeMerge(isPlainObject(current) ? current : {}, value); } else { target[key] = value; } } return target;}const sandboxPrototype = {};const target = Object.create(sandboxPrototype);const payload = JSON.parse('{"__proto__":{"isAdmin":true}}');safeMerge(target, payload);const fresh = Object.create(sandboxPrototype);console.log(fresh.isAdmin);console.log(Object.hasOwn(target, "__proto__"));const dangerousKeys = new Set(["__proto__", "constructor", "prototype"]);function safeSetByPath(target, path, value) { const parts = path.split("."); if (parts.some((part) => dangerousKeys.has(part))) return false; let current = target; for (const part of parts.slice(0, -1)) { if (!Object.hasOwn(current, part) || typeof current[part] !== "object") current[part] = {}; current = current[part]; } current[parts.at(-1)] = value; return true;} const target = {};console.log(safeSetByPath(target, "a.__proto__.polluted", true));console.log(({}).polluted);`target[key] = value` when `key` comes from a request bodyA deep merge reads `target[key]` before checking whether `key` is dangerous`set(obj, "a.__proto__.polluted", true)`Skip `__proto__`, `constructor`, and `prototype` before reading the targetRead permission flags with `Object.hasOwn(config, "isAdmin")`Store user-supplied keys in a `Map`Freeze `Object.prototype` in a tightly controlled runtimeRun Node with `--disable-proto=throw`
Sort each practice by whether it is vulnerable, a safer default, or defense in depth. Read the explanations because order matters.
structuredClone can copy data without invoking setters on the target because it creates a fresh clone, but it is not validation. A JSON reviver can drop dangerous keys while parsing. Schema validation should reject unknown fields before they reach merge or path code.
Object.create(null) and Map
DATA STRUCTURESSometimes the safest fix is to stop using a plain object as an open-ended dictionary. A null-prototype object has no inherited toString, no inherited constructor, and no inherited __proto__ accessor. That means the dangerous-looking names are just own keys. The trade-off is also real: there is no inherited toString or hasOwnProperty, so use static helpers such as Object.entries and Object.hasOwn.
const dict = Object.create(null);dict["__proto__"] = "kept as data";dict.constructor = "also data"; console.log(dict["__proto__"]);console.log("toString" in dict);console.log(JSON.stringify(dict));A Map is often clearer for user-supplied key/value data. It does not use property lookup for entries, accepts non-string keys, has get, set, has, delete, and size, and keeps insertion order explicit.
const flags = new Map();flags.set("__proto__", "kept as data");flags.set("constructor", "also data"); console.log(flags.get("__proto__"));console.log(({}).polluted);| Choice | Use it for | Caution |
|---|---|---|
Plain object {} | Known fields controlled by your code | Inherited names exist; __proto__ assignment has legacy behavior; use Object.hasOwn for decisions. |
Object.create(null) | String-key dictionaries whose keys come from users | No inherited keys and no toString or hasOwnProperty; use static Object helpers. |
Map | Key/value collections with arbitrary user keys or non-string keys | Not JSON by default; convert entries deliberately for storage or network boundaries. |
Frozen Object.prototype | Locked-down apps that own their whole runtime | Defense in depth only; compatibility risk and not a replacement for safe merge code. |
Freezing prototypes and Node flags
Object.freeze(Object.prototype) can make direct pollution attempts fail because the shared prototype is no longer extensible or writable for its data properties. Some locked-down apps do this very early, before application code runs. The risk is compatibility: old libraries, polyfills, tests, or instrumentation that patch built-in prototypes may break. Freezing is defense in depth, not a substitute for safe merge code.
--disable-proto flagNode documents --disable-proto=delete and --disable-proto=throw. delete removes Object.prototype.__proto__; throw makes accesses throw ERR_PROTO_ACCESS. The flag narrows the __proto__ route, but it does not validate input or remove the constructor.prototype route.
node --disable-proto=delete server.jsnode --disable-proto=throw server.jsUse runtime flags and frozen prototypes only when you control compatibility. Keep the main fixes in code: schema validation, filtered keys, own-property reads, null-prototype dictionaries, and Map where a map is the real data model.
Impact and real CVEs
HISTORYPrototype pollution impact depends on what the application reads later. The simplest bug is authorization bypass: code checks if (user.isAdmin) and a polluted default supplies true. Denial of service can happen when polluted methods, types, or options make code throw or recurse. Gadget chains are worse: a polluted option may flow into a template, sanitizer, command runner, or server-side framework and become XSS or remote code execution. Treat those as conceptual risks and audit each gadget path rather than assuming every pollution is instantly exploitable.
| Library | CVE and year | Pattern |
|---|---|---|
lodash merge, mergeWith, defaultsDeep | CVE-2018-3721 (2018), CVE-2018-16487 (2018) | Recursive merge copied __proto__ / constructor-shaped input into prototypes. |
jQuery $.extend(true, ...) | CVE-2019-11358 (2019) | Deep extend could write attacker-controlled properties onto Object.prototype. |
| minimist | CVE-2020-7598 (2020) | Command-line parsing accepted prototype paths from arguments. |
| qs | CVE-2022-23529 (2022) | Query-string parsing had prototype pollution paths in nested parameters. |
Citation trail: NVD CVE-2018-3721 and GitHub Advisory CVE-2018-16487 for lodash merge/defaultsDeep, NVD CVE-2019-11358 for jQuery.extend, NVD CVE-2020-7598 for minimist, and GitHub Advisory CVE-2022-23529 for qs.
Common misconceptions
- “JSON.parse pollutes by itself.” No. It creates an own
__proto__data property. Assignment during merge is the dangerous step. - “Block only
__proto__.” Also blockconstructorandprototypeat every depth. - “A null-prototype object is always better.” It is better for dictionaries with untrusted keys, but plain records with known fields remain readable and natural.
- “Freezing prototypes fixes the app.” It is a guardrail with compatibility costs. Unsafe merge code still needs to be fixed.
- “CVE means every app using the package had RCE.” The library bug creates the primitive. Exploitability depends on reachable input and useful gadget code.
- “Checking `config.isAdmin` is fine because the config is small.” Security decisions should require own data or validated defaults.
| Defense | Protects against | Does not replace |
|---|---|---|
| Key filtering | Known prototype traversal names in merge/set helpers | Schema validation and own-property reads |
| Schema validation | Unexpected fields before they reach business logic | Safe code in lower-level merge helpers |
Object.hasOwn | Inherited defaults influencing decisions | Cleaning already-polluted prototypes |
Object.create(null) / Map | Inherited keys in dictionaries | Validating values and allowed fields |
Practice exercises
5 EXERCISESPredict the three console lines.
const parsed = JSON.parse('{"__proto__":{"isAdmin":true}}');
console.log(Object.hasOwn(parsed, "__proto__"));
console.log(Object.getPrototypeOf(parsed) === Object.prototype);
console.log(({}).isAdmin);const parsed = JSON.parse('{"__proto__":{"isAdmin":true}}');
console.log(Object.hasOwn(parsed, "__proto__"));
console.log(Object.getPrototypeOf(parsed) === Object.prototype);
console.log(({}).isAdmin);The parsed object owns a data property named __proto__, keeps Object.prototype as its prototype, and no fresh object has isAdmin.
What does the final pollution check print?
function isObjectLike(value) {
return value !== null && (typeof value === "object" || typeof value === "function");
}
function unsafeMerge(target, source) {
for (const key of Object.keys(source)) {
const value = source[key];
if (isObjectLike(value) && isObjectLike(target[key])) {
unsafeMerge(target[key], value);
} else {
target[key] = value;
}
}
return target;
}
const parsed = JSON.parse('{"__proto__":{"isAdmin":true}}');
console.log(Object.hasOwn(parsed, "__proto__"));
console.log(Object.getPrototypeOf(parsed) === Object.prototype);
console.log(({}).isAdmin);
try {
unsafeMerge({}, parsed);
console.log(({}).isAdmin);
} finally {
delete Object.prototype.isAdmin;
}The final pollution check prints true. The snippet cleans up with delete Object.prototype.isAdmin in finally.
Type the two printed values.
const dangerousKeys = new Set(["__proto__", "constructor", "prototype"]);
function isPlainObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function safeMerge(target, source) {
for (const key of Object.keys(source)) {
if (dangerousKeys.has(key)) continue;
const value = source[key];
if (isPlainObject(value)) {
const current = Object.hasOwn(target, key) ? target[key] : undefined;
target[key] = safeMerge(isPlainObject(current) ? current : {}, value);
} else {
target[key] = value;
}
}
return target;
}
const sandboxPrototype = {};
const target = Object.create(sandboxPrototype);
const payload = JSON.parse('{"__proto__":{"isAdmin":true}}');
safeMerge(target, payload);
const fresh = Object.create(sandboxPrototype);
console.log(fresh.isAdmin);
console.log(Object.hasOwn(target, "__proto__"));const dangerousKeys = new Set(["__proto__", "constructor", "prototype"]);
function isPlainObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function safeMerge(target, source) {
for (const key of Object.keys(source)) {
if (dangerousKeys.has(key)) continue;
const value = source[key];
if (isPlainObject(value)) {
const current = Object.hasOwn(target, key) ? target[key] : undefined;
target[key] = safeMerge(isPlainObject(current) ? current : {}, value);
} else {
target[key] = value;
}
}
return target;
}
const sandboxPrototype = {};
const target = Object.create(sandboxPrototype);
const payload = JSON.parse('{"__proto__":{"isAdmin":true}}');
safeMerge(target, payload);
const fresh = Object.create(sandboxPrototype);
console.log(fresh.isAdmin);
console.log(Object.hasOwn(target, "__proto__"));The safe merge prints undefined for the fresh object's inherited lookup and false for an own __proto__ copy.
Which built-in keyed collection keeps __proto__ as data instead of a property accessor?
Use Map when user-supplied strings should be keys in a collection. Object.create(null) is also valid for string dictionaries, but Map is the dedicated keyed collection.
A polluted default makes config.isAdmin truthy. Which expression proves the flag is owned by config?
const defaults = { isAdmin: true };
const requestConfig = Object.create(defaults);
console.log(requestConfig.isAdmin);
console.log(Object.hasOwn(requestConfig, "isAdmin"));if (Object.hasOwn(config, "isAdmin") && config.isAdmin) {
allowAdminAction();
}The check now requires isAdmin to be an own property before trusting its value.
Quiz: check your understanding
8 QUESTIONSFor each question, name the object that receives the write, then ask whether later code reads own data or inherited data.
Question 1 of 8What is prototype pollution?
Choose an answer to see the explanation.
Question 2 of 8What does JSON.parse do with an
__proto__member?Read the code, then predictconst parsed = JSON.parse('{"__proto__":{"isAdmin":true}}'); console.log(Object.hasOwn(parsed, "__proto__")); console.log(Object.getPrototypeOf(parsed) === Object.prototype);Choose an answer to see the explanation.
Question 3 of 8What does the unsafe merge proof print at the end?
Read the code, then predictfunction isObjectLike(value) { return value !== null && (typeof value === "object" || typeof value === "function"); } function unsafeMerge(target, source) { for (const key of Object.keys(source)) { const value = source[key]; if (isObjectLike(value) && isObjectLike(target[key])) { unsafeMerge(target[key], value); } else { target[key] = value; } } return target; } const parsed = JSON.parse('{"__proto__":{"isAdmin":true}}'); console.log(Object.hasOwn(parsed, "__proto__")); console.log(Object.getPrototypeOf(parsed) === Object.prototype); console.log(({}).isAdmin); try { unsafeMerge({}, parsed); console.log(({}).isAdmin); } finally { delete Object.prototype.isAdmin; }Choose an answer to see the explanation.
Question 4 of 8Which payload can reach
Object.prototypeeven when__proto__is blocked?Read the code, then predictfunction isObjectLike(value) { return value !== null && (typeof value === "object" || typeof value === "function"); } function unsafeMerge(target, source) { for (const key of Object.keys(source)) { const value = source[key]; if (isObjectLike(value) && isObjectLike(target[key])) { unsafeMerge(target[key], value); } else { target[key] = value; } } } try { unsafeMerge({}, JSON.parse('{"constructor":{"prototype":{"polluted":"yes"}}}')); console.log(({}).polluted); } finally { delete Object.prototype.polluted; }Choose an answer to see the explanation.
Question 5 of 8Why should permission checks use own-property reads?
Read the code, then predictconst defaults = { isAdmin: true }; const requestConfig = Object.create(defaults); console.log(requestConfig.isAdmin); console.log(Object.hasOwn(requestConfig, "isAdmin"));Choose an answer to see the explanation.
Question 6 of 8What does a null-prototype dictionary prove?
Read the code, then predictconst dict = Object.create(null); dict["__proto__"] = "kept as data"; dict.constructor = "also data"; console.log(dict["__proto__"]); console.log("toString" in dict); console.log(JSON.stringify(dict));Choose an answer to see the explanation.
Question 7 of 8What does Map do with dangerous-looking key names?
Read the code, then predictconst flags = new Map(); flags.set("__proto__", "kept as data"); flags.set("constructor", "also data"); console.log(flags.get("__proto__")); console.log(({}).polluted);Choose an answer to see the explanation.
Question 8 of 8What is the honest role of freezing
Object.prototype?Choose an answer to see the explanation.
Key takeaways
- Prototype pollution is a property-write bug that turns input keys into inherited defaults.
JSON.parsecreates an own__proto__data property; unsafe assignment or deep merge makes it dangerous.- Block
__proto__,constructor, andprototypebefore any target lookup in merge and path helpers. - Use
Object.hasOwnfor security decisions, and preferObject.create(null)orMapfor user-key dictionaries. - Freezing prototypes and Node's
--disable-protoflags are defense in depth with compatibility limits.
Remember the one-liner.
Do not let untrusted keys decide where your object graph writes; validate, filter, and read only the data you own.
Up next: supply-chain security, where the same defensive mindset moves from object keys to the packages you install.