Prototype pollution
Learn how unsafe JavaScript merges, JSON, query strings, and path helpers can create inherited defaults, then block prototype pollution safely.
- 01Follow the pollution pathShow how the keys
__proto__andconstructor.prototypeturn input data into a write on a shared prototype. - 02Make merges and path helpers safeSkip dangerous keys before reading the target, and check for own properties before making a decision.
- 03Pick a safer place for keysCompare plain objects, null-prototype objects,
Map, frozen prototypes, input checks, and Node flags.
Prototype pollution in plain words
Most JavaScript objects have a prototype. A prototype is another object that JavaScript checks when an object doesn't have a property itself. Many objects can share one prototype.
Prototype pollution is a bug where outside data adds a property to a shared prototype. It happens in three steps:
- Input from a user contains a special key, such as
__proto__,constructor, orprototype. - A helper copies that input into an object. It treats the special key like any other key.
- The copy lands on the shared prototype. Now every object that uses that prototype seems to have the new property.
The helper might be a merge (code that copies keys from one object into another), a parser, or a function that sets a value by its path, like "a.b.c".
In one sentence: copying keys from outside data into objects can change every object that shares the polluted prototype. This lesson uses ideas from the prototype chain, Object.create, built-in prototypes, freezing and sealing, JSON, classic utilities, Map & Set, property descriptors, and XSS.
Prototype pollution happens when outside data decides where a property is written, and the write lands on a prototype. After that, objects read a value that nobody set on them.
Picture an apartment building. Each door has its own notes. Everyone also reads one shared noticeboard in the hall.
Someone pins a fake notice on the board: "All residents are staff." Now every apartment without its own note seems to be staff. Nobody changed those doors. One notice on the shared board did it.
- In real life: A private note on one door
- In JavaScript: A property that one object has itself (an own property)
- 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 value on the prototype, like
isAdmin: true - In real life: Checking the tenant's own ID
- In JavaScript:
Object.hasOwn(user, "isAdmin")
Some examples add a property to Object.prototype on purpose. They run in a separate, fresh page (an iframe) or a fresh test context. Each one deletes the property again in a finally block, which runs even if the code fails. The step-through demos use their own small prototype object, so this page's real Object.prototype never changes.
Why one prototype reaches many objects
FOUNDATIONWhen you read a property, JavaScript looks in two places, in order:
- First it looks on the object itself.
- If the property is not there, it looks on the object's prototype, then that prototype's prototype, and so on. This line of objects is the prototype chain.
Almost every plain object ends its chain at Object.prototype. So if a property is added to Object.prototype, an empty object like {} can read it too. A property found this way is inherited. A property the object has itself is an own property.
a and b own nothing, so JavaScript looks in the prototype they share. One value written there shows up on both.The special key __proto__ makes this risky. It is an old accessor: a property that runs code when you read or write it. It lives on Object.prototype, so plain objects inherit it.
- Reading
obj["__proto__"]gives back the object's prototype. - Writing
obj["__proto__"] = otherchanges that one object's prototype toother. - A deep merge (a merge that also merges objects nested inside) is worse. It can read the prototype and then write new properties into it.
The next example shows why an inherited value is a problem for a permission check.
const defaults = { isAdmin: true };const requestConfig = Object.create(defaults); console.log(requestConfig.isAdmin);console.log(Object.hasOwn(requestConfig, "isAdmin"));Line 1 makes a defaults object with isAdmin: true. Line 2 uses Object.create(defaults) to make requestConfig, a new empty object whose prototype is defaults. Line 4 prints true, because the read finds isAdmin on the prototype. Line 5 asks Object.hasOwn(requestConfig, "isAdmin"): "does this object have isAdmin itself?" It prints false.
So a plain read can't tell you where a value came from. For permission checks and settings, check that the property is the object's own.
The in operator doesn't help here. It also looks along the prototype chain, so it can't tell own from inherited. Line 2 finds role on the prototype and prints true. Line 3 checks only the object itself and prints false.
in finds inherited properties tooconst config = Object.create({ role: "admin" });console.log("role" in config);console.log(Object.hasOwn(config, "role"));JSON is safe until a merge copies it
STEP THROUGHAttack input often arrives as JSON text. JSON.parse turns that text into an object. When the text has a key named "__proto__", JSON.parse does not run the __proto__ accessor. It just makes a normal own property whose name is "__proto__".
So the parsed object is not polluted. The danger starts later, when a merge copies that key into another object. The example below has two parts:
- Lines 1 to 14 define
unsafeMerge, a deep merge with no safety checks. - Lines 16 to 25 parse the input, check it, merge it, and clean up.
__proto__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;}Here is what each highlighted line does:
- Line 16 parses the text into
parsed. - Line 17 prints
true:parsedhas its own key named__proto__. - Line 18 prints
true: the prototype ofparsedis still the normalObject.prototype. - Line 19 prints
undefined: an empty object has noisAdminyet. - Line 21 runs
unsafeMerge. It follows the__proto__key intoObject.prototypeand writesisAdmin: truethere. - Line 22 prints
true: now every empty object readsisAdminfrom the prototype. - Line 24, in the
finallyblock, deletes the property again.
JSON.parse only makes a normal key named __proto__. The unsafe merge is the step that writes isAdmin into the shared prototype.Object.assign is a related trap. It copies each key with a normal write, like target[key] = value. A normal write of __proto__ runs the accessor, so it changes the prototype of target. It does not change Object.prototype. Here is what happens:
- Line 4 copies the input into
target. - Line 5 prints
true, and line 6 printsfalse:targetcan readisAdmin, but it doesn't own it. - Line 7 prints
false: the prototype oftargetis no longerObject.prototype. - Line 8 prints
undefined: other plain objects are not affected. - Line 10, in
finally, puts the normal prototype back, so line 12 printsundefined.
const payload = JSON.parse('{"__proto__":{"isAdmin":true}}');const target = {};try { Object.assign(target, payload); console.log(target.isAdmin); console.log(Object.hasOwn(target, "isAdmin")); console.log(Object.getPrototypeOf(target) === Object.prototype); console.log(({}).isAdmin);} finally { Object.setPrototypeOf(target, Object.prototype);}console.log(target.isAdmin);Now step through an unsafe merge one line at a time. This version uses its own small prototype object, called sandboxPrototype, instead of the real Object.prototype.
Step through how an unsafe merge turns a __proto__ key into a value that every object reads.
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);In the playground, pick a merge and run it. It runs inside a hidden iframe, which has its own Object.prototype, so this page stays clean.
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__ key?not runnot runPick a merge and run it. It runs inside a hidden iframe, so this page's own Object.prototype never changes.
Unsafe deep merges and path setters
ATTACK SHAPESThe unsafe code is usually small. It is one of these two lines, where key comes from the input:
obj[key] = value, a write with a key chosen by the input.merge(target[key], value), a deep merge into whatevertarget[key]gives back.
A path setter is a helper that sets a nested value from a text path. For example, setByPath(obj, "a.b", 1) sets obj.a.b to 1. It splits the path at each dot into segments ("a", "b") and walks them one by one.
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;}Line 13 calls setByPath with the path "a.__proto__.polluted". Here is the walk:
- Segment
a: nothing is there yet, so line 5 makes a new empty object, and line 6 moves into it. - Segment
__proto__: reading it gives back that object's prototype,Object.prototype. It isn't missing, so line 5 makes nothing new, and line 6 moves into it. - Segment
polluted: line 8 writestrueontoObject.prototype.
Line 14 prints true for an empty object. Line 16 deletes the property again.
Form parsers and URL query parsers often build objects the same way. A URL like ?__proto__[isAdmin]=1 or a path like a.__proto__.polluted is dangerous if the parser writes each segment without checking it.
Brackets and dots can name the same property: profile[__proto__] means the same as profile.__proto__. So turn the path into a clean list of segments first, then check each segment. This is called normalizing the path. The next example only checks a path; it never writes to an object.
- Line 1 starts with a bracket-style path for a profile field.
- Lines 2 to 5 turn each
[into a dot, remove each], split at the dots, and drop empty parts. - Line 7 prints the normalized path,
profile.__proto__.isAdmin. - Line 8 prints
true: one segment is a blocked key, so the code can refuse before any write.
const parts = "profile[__proto__][isAdmin]" .replaceAll("[", ".") .replaceAll("]", "") .split(".") .filter(Boolean);const dangerousKeys = new Set(["__proto__", "constructor", "prototype"]);console.log(parts.join("."));console.log(parts.some((part) => dangerousKeys.has(part)));Blocking only __proto__ is not enough. A second route uses constructor and then prototype:
- Every plain object inherits a
constructorproperty. It points to theObjectfunction. Object.prototypeis theprototypeproperty of that function.- So the input
{"constructor":{"prototype":{...}}}leads a careless deep merge straight toObject.prototype, if the merge also walks into functions. TheisObjectLikecheck on line 2 lets it do that.
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;}Line 16 merges that input into an empty object. Line 17 prints yes: an empty object now reads polluted from Object.prototype. Line 19 deletes it again.
So a safe merge treats all three names as dangerous: __proto__, constructor, and prototype. It checks for them at every level of nesting, before it reads anything from the target.
Block dangerous keys early
STEP THROUGHA safe merge does three simple things:
- It skips the dangerous keys (
__proto__,constructor,prototype) before it reads anything from the target. - It only accepts input fields you expect. Checking input against a list of allowed fields and types is called schema validation.
- When a value decides something important, it reads only own properties.
Step through the safe merge below. It gets the same input as the unsafe merge. Line 7 checks the key first. The key is __proto__, so the merge skips it.
Step through the same input being skipped by a safe merge.
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__"));__proto__ key. unsafeMerge follows it into the prototype; safeMerge checks the key first and skips it.A path setter needs the same guard. In the next example, line 4 checks every segment before the walk starts. Line 15 prints false: the setter refused. Line 16 prints undefined: no object was polluted.
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-chosen keys in a `Map`Freeze `Object.prototype` in an app where you control all the codeRun Node with `--disable-proto=throw`
Sort each habit into one group: unsafe, a safer default, or an extra layer of protection. Read each explanation after you sort.
A few other tools can help, but none of them checks the input for you:
structuredClonemakes a fresh copy of data. It doesn't run the target's accessors, but it doesn't remove bad keys either.- A reviver is a function you pass to
JSON.parse. It can drop dangerous keys while the text is parsed. - Schema validation should reject unknown fields before they reach any merge or path code.
A profile form should never choose its own permission fields. The next example uses an allowlist: a list of the only fields you accept.
- Line 1 parses input that tries to send
isAdminand__proto__. - Line 2 allows only
displayName. - Line 3 makes the result with
Object.create(null): an object with no prototype at all. - Lines 4 to 8 copy a field only if it is allowed and its value is a string.
- Line 9 prints
Asha. Line 10 printsfalse:isAdminwas not copied. Line 11 printsundefined: no prototype changed.
The allowlist guards the entry point. Lower-level merge and path helpers still need their own key checks, because data can reach them by other routes.
const input = JSON.parse('{"displayName":"Asha","isAdmin":true,"__proto__":{"isAdmin":true}}');const allowedFields = new Set(["displayName"]);const profile = Object.create(null);for (const key of Object.keys(input)) { if (allowedFields.has(key) && typeof input[key] === "string") { profile[key] = input[key]; }}console.log(profile.displayName);console.log(Object.hasOwn(profile, "isAdmin"));console.log(({}).isAdmin);Object.create(null) and Map
DATA STRUCTURESSometimes the safest fix is to stop using a plain object to store keys that users choose. Two other containers keep those keys as plain data.
The first is a null-prototype object, made with Object.create(null). It has no prototype, so it inherits nothing:
- No inherited
__proto__accessor, so__proto__is just a normal key. - No inherited
constructor, so that is just a normal key too. - No inherited
toStringorhasOwnProperty. Use the helpers onObjectinstead, such asObject.entriesandObject.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));Line 1 makes dict with no prototype. Lines 2 and 3 store two values. Line 5 prints kept as data. Line 6 prints false: there is no toString to find. Line 7 prints both keys as JSON.
The second is a Map. A Map stores entries (key and value pairs) apart from its properties, so a key never reaches a prototype. It also has more to offer:
- Keys can be any value, not just strings.
- It has clear methods:
get,set,has,delete, and asizeproperty. - It keeps entries in the order you added them.
const flags = new Map();flags.set("__proto__", "kept as data");flags.set("constructor", "also data"); console.log(flags.get("__proto__"));console.log(({}).polluted);Line 5 prints kept as data. Line 6 prints undefined: no prototype changed.
__proto__ is just a name with a value stored under it. No prototype is reached or changed.A Map has one catch when you send data as JSON: JSON.stringify skips its entries. Line 1 stores a user key. Line 2 reads its value. Line 3 prints {}, an empty object. When you need JSON, convert the entries yourself. When the JSON comes back, check it again.
const flags = new Map([["__proto__", "kept as data"]]);console.log(flags.get("__proto__"));console.log(JSON.stringify(flags));The table compares four choices. Each row says what the choice is good for and what to watch out for.
| What you use | When it fits | What to watch out for |
|---|---|---|
A plain object {} | Use it for fields your own code knows about. | It inherits names like constructor, and writing __proto__ runs the old accessor. Use Object.hasOwn for decisions. |
Object.create(null) | Use it for string keys that users choose. | It inherits nothing, not even toString or hasOwnProperty. Use the helpers on Object instead. |
A Map | Use it for any keys that users choose, including keys that aren't strings. | JSON.stringify skips its entries. Convert them yourself before you save or send them. |
A frozen Object.prototype | Use it in a locked-down app where you control all the code. | It is only an extra layer. It can break other code, and it doesn't replace a safe merge. |
Freezing prototypes and Node flags
Object.freeze(Object.prototype) locks the shared prototype. After that, nobody can add new properties to it or change its normal values, so direct pollution attempts fail. Some locked-down apps do this at the very start, before any other code runs.
The risk is that other code may stop working. Old libraries, polyfills (code that adds missing built-in features), tests, and monitoring tools sometimes change built-in prototypes on purpose. Freezing is an extra layer of protection. It does not replace a safe merge.
--disable-proto flagNode can start with a --disable-proto flag. It has two modes:
--disable-proto=deleteremovesObject.prototype.__proto__.--disable-proto=throwmakes every use of it throw anERR_PROTO_ACCESSerror.
Both close the __proto__ route. Neither one checks your input, and neither one closes the constructor.prototype route.
node --disable-proto=delete server.jsnode --disable-proto=throw server.jsLine 1 starts server.js in delete mode. Line 2 starts it in throw mode.
Use frozen prototypes and Node flags only when you know all your code still works with them. Keep the main fixes in your own code:
- Check input against the fields you allow.
- Skip the dangerous keys.
- Read own properties for important decisions.
- Store user-chosen keys in a null-prototype object or a
Map.
Impact and real CVEs
HISTORYHow much harm pollution does depends on what the app reads later. Here are the main kinds of harm, from simplest to worst:
- Skipping a permission check. The code checks
if (user.isAdmin), and a polluted value givestrue. - Denial of service. A polluted value makes code crash or loop forever, so the app stops working for everyone.
- A chain into worse bugs. A polluted setting flows into other code, such as a template, an HTML cleaner, or a command runner. This could lead to XSS, or to an attacker running commands on the server.
These are possible risks, not certain ones. For each case, follow the polluted value and see what code actually reads it.
A CVE is a public ID number for a known security bug. The table lists real prototype pollution bugs in popular libraries. Each row gives the library, the CVE number and year, and what the bug did.
| Which library | CVE number and year | What went wrong |
|---|---|---|
lodash merge, mergeWith, defaultsDeep | CVE-2018-3721 (2018), CVE-2018-16487 (2018) | Its deep merge copied __proto__ and constructor keys from input into prototypes. |
jQuery $.extend(true, ...) | CVE-2019-11358 (2019) | Its deep copy could write properties chosen by an attacker onto Object.prototype. |
| minimist | CVE-2020-7598 (2020) | It read command-line arguments and accepted paths that reached a prototype. |
| qs | CVE-2022-24999 (2022) | Crafted nested query parameters could hang a Node process (denial of service). |
Sources: 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 NVD CVE-2022-24999 for qs.
To check your own app:
- Find where keys from requests enter your code.
- Find what important code reads those values later.
- Check the versions of the packages you use, and the packages they use.
- Update any affected package to a fixed version that is still supported.
- Test your parser settings.
A CVE in a package you use does not prove your app has a permission bypass. It doesn't prove attackers can run commands either. It means you should check.
Common misconceptions
- "JSON.parse pollutes by itself." No. It makes a normal own key named
__proto__. The merge that copies it is the dangerous step. - "Blocking
__proto__is enough." No. Also blockconstructorandprototype, at every level of nesting. - "A null-prototype object is always better." No. It is best for keys that users choose. For objects with fields your code knows, a plain object is easier to read.
- "Freezing prototypes fixes the app." No. It is an extra layer that can break other code. The unsafe merge still needs fixing.
- "A CVE means every app using the package can be taken over." No. The package bug lets pollution happen. Real harm depends on whether attackers can reach it and what code reads the polluted value.
- "Checking
config.isAdminis fine because the config is small." No. Size doesn't matter. Important decisions should read own properties or checked defaults.
These four defenses are easy to mix up. Each row says what one defense stops and what it can't do alone.
| Which defense | What it stops | What it doesn't replace |
|---|---|---|
| Skipping dangerous keys | It stops merge and path helpers from following __proto__, constructor, or prototype. | It doesn't replace checking fields or reading own properties. |
| Schema validation | It stops unexpected fields before they reach the rest of your code. | It doesn't replace key checks inside merge and path helpers. |
Object.hasOwn | It stops inherited values from deciding a permission or setting. | It doesn't clean a prototype that is already polluted. |
Object.create(null) or Map | It stops user-chosen keys from reaching a prototype. | It doesn't check the values or which fields are allowed. |
Practice exercises
5 EXERCISESGuess the three lines the console prints.
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 has its own key named __proto__, so line 2 prints true. Its prototype is still Object.prototype, so line 3 prints true. No prototype changed, so line 4 prints undefined.
What does the last isAdmin 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 last check prints true, because the merge wrote isAdmin onto Object.prototype. The finally block then removes it with delete Object.prototype.isAdmin.
Type the two values the console prints.
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 skips __proto__. A fresh object finds no isAdmin, so line 23 prints undefined. The target has no own __proto__ key, so line 24 prints false.
Which built-in container keeps a key named __proto__ as plain data, not as an accessor?
Use a Map when users choose the keys. Object.create(null) also works for string keys, but Map is built for key and value pairs.
A polluted prototype makes config.isAdmin true. Which expression checks that config has isAdmin itself?
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();
}Now the check only passes when config has isAdmin itself and the value is true.
Quiz: check your understanding
8 QUESTIONSFor each question, first find which object gets the write. Then ask: does the later read find an own value or an inherited one?
Question 1 of 8What is prototype pollution?
Choose an answer to see the explanation.
Question 2 of 8
JSON.parsereads a__proto__key. What does this code print?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 last
console.login this unsafe merge example print?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 8This input uses
constructorinstead of__proto__. What does it print?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 8
requestConfiggetsisAdminfrom its prototype. What does this code print?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 this null-prototype object print?
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 this Map example print?
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 freezing
Object.prototypegood for?Choose an answer to see the explanation.
Key takeaways
- Prototype pollution is a bug where a key from the input makes a write land on a shared prototype. Then many objects read the new value.
JSON.parsemakes a normal own key named__proto__. It becomes dangerous only when an unsafe write or deep merge copies it.- In merge and path helpers, skip
__proto__,constructor, andprototypebefore reading anything from the target. - Use
Object.hasOwnfor important decisions. Store user-chosen keys inObject.create(null)or aMap. - Frozen prototypes and Node's
--disable-protoflag are extra layers. They can break other code, and they don't replace safe helpers.
Remember the one-liner.
Never let keys from the input decide where your code writes. Check the input, skip dangerous keys, and trust only properties an object owns.
Up next: supply-chain security. It is about the same kind of care, but for the packages you install instead of the keys you copy.