skip to content

In JavaScript, the key `__proto__` behaves differently in an object literal, in the result of `JSON.parse`, and in a bracket assignment. Explain the three cases and what they mean for merging untrusted data into an object.

level: seniorimportance: should knowfreq 40%

answer

  1. same name, three different operations
  2. literal form is a spec special case
  3. parsing defines, copying assigns
  4. the inherited accessor on Object.prototype
  5. recursive merge reaches the shared root

basics

~20 s

In an object literal __proto__: v sets the new object's prototype instead of creating a property. JSON.parse creates an ordinary own property with that name. A bracket assignment on a normal object runs the inherited proto setter and reassigns the prototype, which is how naive merges reach Object.prototype.

solid answer

~50 s

Three different mechanisms share one name. In an object initializer, the non-computed form `{ __proto__: v }` is special-cased by the spec: it sets the new object's prototype when `v` is an object or `null`, and creates no own property. `JSON.parse` builds properties with an internal define operation, so a `"__proto__"` member in JSON becomes a plain own property and the parsed object's prototype is untouched. But `obj[key] = value` is an ordinary assignment, and on any object inheriting from `Object.prototype` the name `__proto__` resolves to an inherited accessor whose setter reassigns the prototype. So parsed data holds the payload harmlessly, and it is the code that copies it — `target[k] = source[k]`, or a recursive merge that walks into `target['__proto__']` — that turns it into prototype pollution. Defences: use `Object.create(null)` or a Map for the target, skip the keys `__proto__`, `constructor` and `prototype`, or define instead of assign.

code

javascript · 17 lines
javascript
const fromLiteral = { __proto__: { poisoned: true } };
console.log(Object.getPrototypeOf(fromLiteral).poisoned);  // true
console.log(Object.hasOwn(fromLiteral, '__proto__'));      // false

const fromJson = JSON.parse('{"__proto__": {"poisoned": true}}');
console.log(Object.getPrototypeOf(fromJson) === Object.prototype);  // true
console.log(Object.hasOwn(fromJson, '__proto__'));                  // true

const key = '__proto__';
const plain = {};
plain[key] = { poisoned: true };
console.log(Object.hasOwn(plain, '__proto__'));            // false - the setter ran
console.log(Object.getPrototypeOf(plain).poisoned);        // true

const dict = Object.create(null);
dict[key] = { poisoned: true };
console.log(Object.hasOwn(dict, '__proto__'));             // true - just a key

go deeper

for a junior

Know that __proto__ is a special name: in an object literal it sets the prototype rather than storing a value, and it is not an ordinary property like the others.

for a middle

Explain the three paths precisely — the literal special case, define-based creation in JSON.parse, and assignment hitting the inherited accessor — and predict what Object.hasOwn and Object.getPrototypeOf report in each.

for a senior

Trace the pollution through a real recursive merge, show why the effect is realm-wide and retroactive, and pick a defence that fits the code path: null-prototype accumulators, defining instead of assigning, or key filtering that also covers constructor.

for a principal

Own the systemic answer: which layer normalizes untrusted input, whether shared built-ins are hardened at startup, and how dependencies that do naive merges are audited so the same class of bug cannot re-enter through a transitive package.

## One name, three mechanisms `__proto__` is not a normal property name, and the confusion is that it means something different depending on how the name reaches the object. **In an object literal.** The specification carves out the non-computed initializer form: in `{ __proto__: v }`, if `v` is an object or `null`, the new object's prototype is set to `v` and no own property is created. This is specified in Annex B (`__proto__` Property Names in Object Initializers) and is implemented everywhere, browsers and Node alike. If `v` is any other primitive, the whole thing is a no-op — no prototype change, no property. Crucially only this exact syntactic form is special: ```js Object.getPrototypeOf({ __proto__: base }) === base; // true Object.hasOwn({ ['__proto__']: base }, '__proto__'); // true - computed key Object.hasOwn({ __proto__() {} }, '__proto__'); // true - method form Object.hasOwn({ ...{ __proto__: base } }, '__proto__'); // false - nothing to spread ``` A computed key, a shorthand, or a method definition all create an ordinary own property instead. **In JSON.** `JSON.parse` does not assign; it creates each member with an internal define-property operation, which never consults setters or the special literal rule. So `JSON.parse('{"__proto__":{"a":1}}')` returns an object whose prototype is still `Object.prototype` and which has an own, enumerable property named `__proto__` holding `{a:1}`. By itself this is inert — the danger is entirely in what happens next. **In an assignment.** `obj[key] = value` is a `Set` operation. It walks the prototype chain looking for an accessor, and `Object.prototype` provides exactly one for this name: the legacy `__proto__` accessor, whose setter calls the internal SetPrototypeOf. So writing `obj['__proto__'] = something` on an ordinary object silently changes that object's prototype and creates no own property. On an object made with `Object.create(null)` there is no such accessor, so the same statement stores an ordinary key. ## How that becomes prototype pollution The interesting failure is not a single assignment onto one object; it is a *recursive* merge, which is everywhere in configuration and options handling: ```js function merge(target, source) { for (const key of Object.keys(source)) { if (source[key] && typeof source[key] === 'object') { merge(target[key] ??= {}, source[key]); } else { target[key] = source[key]; } } return target; } ``` Feed it `JSON.parse('{"__proto__":{"isAdmin":true}}')`. `Object.keys(source)` yields `"__proto__"` — it is a real own key on the parsed object. The recursion reads `target['__proto__']`, which goes through the getter and returns `Object.prototype` itself; since that is not nullish, `??=` leaves it in place, and the next level assigns `isAdmin` onto `Object.prototype`. Now every object in the realm answers `true` for `isAdmin`, including ones created before the attack, because lookup is dynamic. The same shape works through `constructor.prototype` when only `__proto__` is filtered. The damage is realm-wide and silent: authorization checks written as `if (user.isAdmin)`, feature flags read with a bare property access, and `if (options.shell)` style branches in libraries all start seeing values nobody set. ## Defences, in order of strength - **Do not merge into a plain object.** If the accumulator is `Object.create(null)`, `target['__proto__'] = ...` stores a harmless key because there is no inherited setter to hijack. A `Map` is even further removed, since its keys are not property names at all. - **Define instead of assign.** `Object.defineProperty(target, key, { value, writable: true, enumerable: true, configurable: true })` creates an own property without consulting inherited setters, which neutralises the mechanism at the point of the write. - **Reject the dangerous keys.** Skip `__proto__`, `constructor` and `prototype` explicitly while iterating. This is cheap and worth doing even alongside the above, and it is what hardened merge and query-string libraries do. - **Freeze the root.** `Object.freeze(Object.prototype)` at startup makes the final assignment fail. It is a blunt, global measure that can break libraries which extend built-ins, so treat it as defence in depth rather than the primary fix. ## Reading the difference back A quick way to tell which mechanism ran is to ask for the own property: ```js Object.hasOwn(value, '__proto__'); // true -> inert data, produced by JSON.parse or define Object.getPrototypeOf(value); // changed -> a literal or an assignment ran ``` And a practical note on inspection: printing an object that has an own `__proto__` key can look identical in a console to one whose prototype changed, so verify with `Object.hasOwn` and `Object.getPrototypeOf` rather than trusting the log output.

  • Why is `JSON.parse` itself not the vulnerability here?
    Because it creates properties with an internal define operation rather than assignment. Inherited setters are never consulted, so a `"__proto__"` member lands as an ordinary own property and the parsed object's prototype stays `Object.prototype`. The payload is inert until some later code copies that key onto another object with `=`.
  • You filtered out `__proto__` in your merge — is that enough?
    No. `constructor` is still reachable: a payload shaped `{"constructor": {"prototype": {"isAdmin": true}}}` walks from the target to its constructor function and then to its `prototype` object, hitting the same shared root. Filter `constructor` and `prototype` too, or better, merge into a null-prototype object so no inherited path exists.
  • How would you detect that pollution has already happened in a running process?
    Enumerate the built-in root directly: `Object.getOwnPropertyNames(Object.prototype)` should contain only the known standard members, so anything else is an injected key. In tests, snapshot that list before and after exercising the merge path and fail on any difference — it catches the class of bug rather than one payload.
  • Does `{ __proto__: 'a string' }` set anything?
    No. The literal special case applies only when the value is an object or `null`; for any other primitive it is a no-op — the prototype is unchanged and no own property is created, so the key silently disappears. That asymmetry surprises people who expect either a stored value or an error.

saying these in an interview costs you the question

  • Says JSON.parse changes the parsed object's prototype
  • Thinks a literal __proto__ key creates an own property
  • Claims only the attacked object is affected, not later ones
  • Believes filtering __proto__ alone closes the hole
  • Assumes a computed ['__proto__'] key behaves like the literal form

context