> ## Content Index
> Fetch the complete content index at: https://debugly.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# The JSON.parse That Became a Prototype Pollution
- URL: https://debugly.dev/json-prototype-pollution/
- Published: 2026-10-09T12:34:00.000Z
- Updated: 2026-10-10T13:59:56.000Z
- Description: The payload was ordinary JSON and the merge was ordinary code, but a __proto__ key in the body wrote to Object.prototype, and every object in the process…
- Author: Rohit Bhadani
- Tags: Security, Node.js, Web Development

The anomaly was that an admin flag appeared on accounts that had never been granted it, and the value matched a field no form submits. The trail ended in a merge: a utility that deep merged an untrusted JSON body into a settings object, and the body, written by an attacker, contained a `__proto__` key. JSON.parse produced it harmlessly, a plain object with a strange property. The merge then walked the keys and assigned through the prototype chain, writing the attacker's field onto Object.prototype, from which every object in the process inherits, and the authorisation check that read the flag from an object that never defined it now found it everywhere.

This is the anatomy of prototype pollution, the injection that targets not a parser but the language's inheritance, and of the merge that is its classic vector.

This was Node 22.14 with a hand rolled deep merge, and the findings apply to every recursive assign that walks untrusted keys.

## Why JSON.parse is innocent and the merge is guilty

JSON.parse returns plain objects. A `__proto__` key in the JSON becomes, in modern engines, an own property named "**proto**" on the parsed object, not a prototype link, so the parse itself does not pollute. The danger begins when code walks that object and assigns its keys onto a target, because assigning to the property named **proto** on a plain object is, in JavaScript, a write to the prototype, and a recursive merge that descends into it writes the attacker's object into Object.prototype. From that moment, property reads on any object that lacks the property fall through to the polluted prototype and return the attacker's value, which is how a flag that was never set appears set on everything.

The defect is therefore a confusion of data and structure, the same family as [SQL injection inside the ORM you trusted](https://debugly.dev/sql-injection-inside-an-orm/), where untrusted input became structure, but here the structure is the language's own inheritance, which makes the blast radius the whole process rather than one query.

## What pollution buys the attacker

The inherited field is a universal default, and its value is whatever the attacker chose, so the purchase is any check that reads an optional property defensively. An authorisation that reads `user.isAdmin` on a user object lacking the field now reads true. A config lookup that falls back to a default now reads the attacker's default. And the most dangerous purchases are the gadget chains: libraries that read a property assumed benign and pass it to something powerful, a template engine reading a method name, a child process spawn reading options, where pollution turns a read into a write or an execution, which is how a property becomes code execution in the worst published chains.

The property is also sticky: it survives until the process restarts, so every request served by the process sees the polluted world, which makes a single successful payload a process wide, persistent compromise, far beyond the one request.

## The vectors, in review order

**The recursive merge of untrusted input.** The classic, and the one to grep for: any deep assign, extend or merge that walks keys from a body, a query, a file. The review question is whether the keys are attacker controlled, and if so the merge is the vulnerability, regardless of how convenient.

**The path based set.** A utility that sets a value at a dot path from input, splitting "a.b.c" and walking, is a merge by another name, and the path segments **proto** or constructor.prototype are the payload. The review treats path setters with the same suspicion as merges.

**The parse then spread into a class instance.** Spreading untrusted parsed data over an object that has methods can, with constructor.prototype paths, reach the class prototype, polluting every instance rather than the global object, a narrower but real variant.

## The fixes that remove the channel

**Refuse the keys.** The merge or setter rejects the dangerous names, **proto**, prototype, constructor, when they appear as keys from untrusted input, and the rejection is the allowlist discipline from [validating uploads by file extension is not validation](https://debugly.dev/file-upload-validation-by-extension/), which is why the two fixes look alike.

**Create without a prototype.** Objects built to hold untrusted data with a null prototype have no chain to pollute, so the payload lands in a dead end, which is the cheapest structural fix for the holding container, though it does not fix a merge that still assigns onto real targets.

**Use the safe primitives.** Structured clone, the JSON schema validation that strips unknown keys, or a map with explicit keys, replace the recursive merge for untrusted input, because the safe version is a parse into a typed shape, the boundary discipline from [reviewing input validation at boundaries](https://debugly.dev/reviewing-input-validation-at-boundaries/), and the typed shape has no room for a prototype key.

**Freeze the prototype where the runtime allows.** Freezing Object.prototype in a hardened service turns pollution writes into thrown errors, loud instead of silent, which is a belt for the braces, not a replacement for refusing the keys.

## The test that catches it

The payload test is two assertions: merge a body containing the proto key, then read the polluted property from a fresh, unrelated object, and assert it is undefined. The test on the fresh object is the point, because pollution is defined by its reach beyond the merge, and a test that checks only the merged object checks the wrong victim.

## What I now do

JSON.parse is safe and the merge is the vulnerability: any recursive assign that walks untrusted keys can write through **proto** into the language's inheritance, making the attacker's value the universal default for the life of the process. Refuse the meta keys at the boundary, hold untrusted data in null prototype or typed shapes, and test pollution by its reach into a fresh object.

The payload was not exotic. It was a key with a name the language reserves, and the merge that trusted every name it met is the one that turned a field into a law. Get that property wrong and every layer above it lies to you politely.