Articles
JavaScript Prototype Pollution Interview Questions: Unsafe Merges and Defensive Object Handling
Practice prototype-pollution interview questions with JSON parsing, Object.assign, unsafe deep merges, own-property checks, and schema-based defenses.
An API accepts a JSON settings object, then merges it into defaults. A reviewer sees "__proto__" in the input and says, “JSON.parse() polluted every object.” That diagnosis is too broad. A strong interview answer identifies the later write operation, shows which object's prototype actually changed, and designs a boundary that rejects untrusted property paths before they are applied.
The mechanism and defenses below follow MDN's prototype pollution guide, its Object.assign() reference, and the JavaScript property model. The code and review exercise are synthetic defensive examples, not candidate reports or a claim about a company's question bank. For related object-model practice, read the JavaScript this guide.
Question 1: What is prototype pollution?
JavaScript objects can inherit properties through a prototype chain. Code that reads config.isAdmin may receive a value from the object's own properties or, if absent, from a prototype. Prototype pollution occurs when an attacker-controlled property name or path causes code to modify a prototype that later affects lookups. The impact depends on which prototype, which property, and which downstream code reads it. Do not treat every dangerous-looking JSON key as an exploit by itself.
The special-looking __proto__ key is an important path, but it is not the whole class. MDN also describes paths through constructor.prototype in unsafe dynamic writes. That matters when a defense only filters one spelling. A vulnerable program often starts with a perfectly ordinary feature—merging configuration, expanding nested query parameters, or copying a user-supplied object into a default object—and then uses untrusted keys as instructions about where to write.
The result might alter just one target object's prototype, or a wider prototype used by many objects, depending on the code path. The security impact is not automatic: a polluted isAdmin property matters only if authorization code wrongly trusts inherited properties, while a changed request option may redirect behavior in another context. In an interview, name the source, write operation, affected object, and harmful read. Without that chain, “pollution” is a label rather than an explanation.
Question 2: Does parsing JSON alone change a prototype?
For the ordinary example below, JSON.parse() creates an own data property named __proto__. It does not immediately change the prototype of every object. The risk appears when later code applies that property with an operation that invokes a setter or follows a dynamic path:
const input = JSON.parse('{"__proto__":{"canReview":true}}');
const target = {};
Object.assign(target, input);
console.log(Object.hasOwn(input, '__proto__')); // true
console.log(Object.getPrototypeOf(target).canReview); // true
console.log(({}).canReview); // undefined in this isolated example
This is a synthetic local demonstration of a changed target prototype, not proof that Object.prototype has been globally modified. Object.assign() reads enumerable source values and assigns them to the target; assignment can invoke setters on the target. The legacy __proto__ setter is the relevant route for an ordinary target in this example. MDN explicitly distinguishes a parsed own property from what happens when that property is merged.
If an interviewer asks whether object spread behaves the same way, answer narrowly. MDN notes that spreading properties into a new object does not trigger that __proto__ setter in the same manner. But object spread is shallow and is not a universal input-validation or safe-deep-merge strategy. A later dynamic write, nested merge, or inherited-property read can still create problems. The defense should be designed around trusted schema and explicit property access, not around swapping one syntax and declaring the data safe.
An original data-flow review table
Use this table to walk from untrusted input to a decision. The facts in the mechanism column come from the linked language and security documentation; the scenario is an original practice exercise.
| Step | What the code does | What the reviewer must ask |
|---|---|---|
| Parse JSON | Creates keys, including possible own __proto__ | Is the key merely data at this point? |
| Merge or assign | Writes source keys into a target | Can this write invoke a setter or follow a nested path? |
| Read a flag | config.canReview may search prototypes | Is an inherited value being treated as trusted authority? |
| Persist or forward | A changed option affects later behavior | Which concrete action could the value alter? |
| Validate | Schema accepts only expected own keys and value types | Are unexpected nested keys rejected before any write? |
The table prevents two opposite mistakes. One is blaming the parser even when it only produced data. The other is dismissing the input because it started as data, while ignoring a dangerous merge or path setter later. The review must follow the full flow. In code review, include library utilities: an application may not contain a hand-written recursive merge, yet a dependency may perform the write on its behalf.
Question 3: How can a deep merge be worse?
A shallow Object.assign() into a fresh ordinary target can alter that target's prototype. A recursive merge or generic setter may traverse keys and write into nested objects. If it lets attacker-controlled segments choose constructor, prototype, or __proto__, the destination can be a shared prototype rather than just one config object. MDN documents these paths. The exact outcome depends on the implementation, so do not assert that every deep merge pollutes globally. Inspect how it enumerates keys, how it chooses the next object, and whether it follows inherited properties.
Consider a helper that takes a list of user-provided path segments and performs a write like obj[first][second] = value. The first lookup may resolve an inherited property. A later assignment can then land somewhere the caller did not intend. A defense that rejects only __proto__ can miss constructor followed by prototype. That is why validating an allowlist of known fields is stronger than trying to enumerate every suspicious string after accepting arbitrary paths.
Also distinguish a pollution primitive from a real exploit. You need a downstream read with authority or effect. A default field inherited from a prototype might cause a truthy conditional, alter a request configuration, or change a formatting option. The remedy is to stop the unsafe write and avoid trusting inherited properties at security-sensitive reads. Fixing only the final read may leave other consumers exposed; fixing only the merge may not repair already-polluted in-memory state until the process is restarted or affected objects are recreated.
Question 4: What are robust defenses?
Begin with schema validation at the input boundary. Define expected keys, types, and nesting; reject or discard unknown fields before a generic merge. For a user profile update, perhaps only displayName and timezone are accepted. The program should map those fields explicitly rather than merge the whole request body into a privileged settings object. This also helps prevent mass assignment unrelated to prototype pollution.
When dynamic dictionaries are required, use data structures that do not inherit the ordinary object prototype, such as Map or Object.create(null), and still constrain permitted keys and value types. A null-prototype object does not inherit the usual __proto__ setter, but that is one boundary, not a license to pass arbitrary keys into other ordinary objects later. When checking whether a value was supplied, Object.hasOwn(obj, key) avoids treating inherited properties as own user input. Do not write if (obj.key) when the distinction between an own flag and an inherited flag controls authorization.
MDN also notes Node's --disable-proto option for some environments. It can remove or throw on the legacy __proto__ accessor, but it does not address every path, including constructor.prototype, or validate business fields. Treat runtime hardening as defense in depth. Keep merge and query-string libraries updated, review their published security behavior, and test with representative untrusted keys at the boundary where they are applied.
For configuration objects, consider setting explicit own defaults such as canReview: false when the field affects a decision. An own property prevents an inherited fallback for that read. But do not rely on defaults alone if a merge can overwrite the own field or mutate a shared object. Keep authorization on trusted identities and explicit checks, not on an arbitrary property copied from request data.
Question 5: How would you test and debug it?
Write a small test against the application's actual parser-and-merge path. Feed a synthetic object with unusual keys, then inspect both Object.getPrototypeOf(target) and an unrelated fresh object. The two observations tell you whether the target changed locally or a shared prototype changed. Check own keys with Object.hasOwn, and test the sensitive read that follows. Reset the test environment between cases so one polluted global prototype does not contaminate later results.
Add cases for a __proto__ input, a constructor/prototype path if the application accepts nested paths, and a harmless unknown key. Assert that the boundary rejects or ignores them according to the product schema. Test the allowlisted fields still work. A regression test that only checks “no exception thrown” is weak; it must verify the prototype and business decision were not changed by the untrusted input.
In incident response, preserve the raw input and the version of the merge/parser library, but avoid logging secrets in full. Identify whether the suspicious key was merely stored as data or actually applied. Search for the first dynamic write, the resulting prototype, and any security-sensitive inherited read. If a long-running process may have a polluted shared prototype, containment may require restarting after the vulnerable write path is fixed. Do not promise that deleting one input record necessarily clears in-memory effects.
A concise interview answer
“I would trace the untrusted key from parser to write to later read. JSON.parse() can create an own __proto__ key without changing a prototype. A later Object.assign() into an ordinary target can invoke the target's setter and change that target's prototype; a recursive or path-based merge can reach other prototypes depending on its implementation. I would validate an allowlisted schema, avoid generic writes from untrusted paths, use own-property checks for trusted decisions, and test the actual target and downstream behavior. I would not claim a global exploit without showing which shared object was modified.”
| Practice question | Connection to this scenario |
|---|---|
| Reason About Nested JSON Schema Validation | Define accepted nested fields before merging. |
| Validate a Third-Party Identity Payload | Treat external object properties as untrusted. |
| Flatten object & Promise.all | Reason about object paths and key traversal. |
| Render Nested JSON in a Code-Style Viewer | Keep displayed JSON distinct from applied configuration. |
| Implement Recursive React JSON Viewer with Collapse | Traverse nested data without treating keys as instructions. |
Next, take a small application merge function and annotate four points: input source, validation boundary, write destination, and first trusted read. If one point is missing, the prototype-pollution diagnosis is incomplete.
Sources and further reading
- MDN: Prototype pollution — mechanisms, impact, and defenses.
- MDN: Object.assign() — source reads and target assignments.
- MDN: Spread syntax — object spread behavior and shallow copying.
- MDN: Object.hasOwn() — own-property checks.