Prototype Pollution in JavaScript Applications

Prototype Pollution in JavaScript Applications

Prototype pollution in JavaScript applications is one of those bugs that hides in plain sight inside a utility function nobody has looked at since the project started. If you maintain a Node.js backend or a front end that merges user-controlled objects, this article explains how prototype pollution works, where it turns into real damage like authentication bypass or remote code execution, and how to find and fix it before an attacker does.

The fix is usually small. Finding the problem is the hard part, because the vulnerable line often looks like ordinary, sensible code.

How Prototype Pollution Actually Works

Almost every JavaScript object inherits from Object.prototype. If an attacker can write a property onto that shared prototype, every object in the process inherits it, including objects the attacker never touched.

The classic entry point is a recursive merge or “deep set” function. Picture a function that walks a JSON body and copies each key into a target object. Send it {“__proto__”: {“isAdmin”: true}} and the function moves into target.__proto__, which is Object.prototype, and writes isAdmin there. From then on, ({}).isAdmin returns true for every request the process handles until it restarts.

Real libraries have shipped exactly this flaw. Lodash’s merge functions were affected by CVE-2018-16487, fixed in 4.17.11, and defaultsDeep was affected by CVE-2019-10744, fixed in 4.17.12. jQuery’s $.extend(true, …) was fixed in 3.4.0 under CVE-2019-11358. Older versions of these packages are still common in lockfiles, so it is worth checking how your dependency chain resolves them.

From Polluted Prototype to Real Impact

Pollution on its own is just a corrupted object. The damage comes from a gadget: code elsewhere that reads a property it never explicitly set and acts on the inherited value.

On the server, typical gadgets include authorization checks like if (user.isAdmin), template engine options, and the options argument of child_process.spawn. Polluting shell or env on that options object has been used to get command execution in Node.js research. The best-known real case is Kibana’s Timelion vulnerability, CVE-2019-7609, where prototype pollution led to remote code execution on versions before 5.6.15 and 6.6.1.

In the browser, gadgets usually lead to DOM XSS. A library reads something like config.scriptSrc or options.html, finds nothing set locally, falls back to the polluted prototype, and injects attacker-controlled markup. The pollution source is often the URL itself, with query string parsers turning ?__proto__[foo]=bar into a nested object.

A Scenario Teams Recognize

A typical case is a SaaS dashboard where a user preferences endpoint deep-merges the request body into a defaults object. It passes code review, because the merge helper is twelve lines copied from a 2017 Stack Overflow answer. Three weeks later, support reports that random free-tier users can see admin-only menu items. The issue disappears after each deploy and comes back hours later.

That intermittent pattern is the giveaway. Pollution lives in process memory, so a restart “fixes” it and the next malicious request brings it back. An experienced application security engineer who sees state-dependent authorization bugs that clear on restart checks the merge and clone helpers first.

Common Myths and Mistakes

Myth: blocking the “__proto__” key is enough. It is not. constructor.prototype reaches the same object: {“constructor”: {“prototype”: {“isAdmin”: true}}} works against many naive merge functions. Filters that only look for the string __proto__ get bypassed regularly, and that is why several CVE fixes needed a second patch.

Mistake: assuming TypeScript protects you. Types disappear at runtime. A body typed as Partial<Settings> still contains whatever keys the client sent, unless a runtime validator such as Zod or Ajv strips unknown properties. This is closely related to mass assignment in API endpoints, and the same rule applies to both: allowlist the fields you accept.

Mistake: treating JSON.parse as the culprit. JSON.parse(‘{“__proto__”:{}}’) creates an ordinary own property called “__proto__” and does not pollute anything. The danger starts when a merge, clone, or set-by-path function later copies that key using normal assignment. Teams that replace JSON.parse and leave the merge helper untouched have fixed nothing.

Mistake: patching the direct dependency and ignoring transitive copies. If npm ls lodash shows four resolved versions, one of them may still be vulnerable.

Fixing and Preventing Prototype Pollution

A seasoned Node.js lead works through these steps in order:

1. Inventory every deep merge, clone, and set-by-path call that touches request data, query strings, or parsed config. Grep for merge, extend, defaultsDeep, set, and any custom recursive helpers.
2. Upgrade known vulnerable packages and add npm audit or an equivalent to CI so regressions fail the build.
3. Validate input against a strict schema at the edge and reject unknown keys, including __proto__, constructor, and prototype.
4. Use prototype-less storage for attacker-influenced maps: Object.create(null) or a Map, which has no prototype chain to walk.
5. Harden the runtime by starting Node with –disable-proto=delete (or throw), or call Object.freeze(Object.prototype) early at startup.

Freezing the prototype has a real edge case. Some older libraries and polyfills patch built-in prototypes when they load, and they will break or throw if the prototype is frozen. Run the full test suite before you ship this change. Legacy apps sometimes have to settle for –disable-proto only, which blocks the __proto__ accessor but not the constructor.prototype route.

Gadget checks also belong in code review. Reading options.x || default is safe only when you use Object.hasOwn(options, ‘x’) or options is prototype-less. In the browser, a strict Content Security Policy limits what a client-side gadget can do, even if pollution still happens.

Detecting It With Testing and Scanning

Automated website security scanning can probe endpoints and URL parameters with __proto__ and constructor.prototype payloads, then watch for changed responses. On the client side, a scanner or tool such as DOM Invader in Burp Suite can show whether ?__proto__[test]=1 appears on Object.prototype after the page loads.

Server-side detection is harder. A successful probe changes global state, so careful testers use harmless properties such as polluting json spaces in Express and watching the response formatting, instead of payloads that break the app. Never run destructive pollution tests against a shared production process. One bad payload can lock out every user until restart. Scanning finds the obvious sources, but a confirmed gadget chain to RCE usually still needs manual penetration testing. The same logic applies to insecure deserialization, where a tool finds the sink and a human proves the impact.

FAQ

Does prototype pollution affect Deno and Bun as well as Node.js?
Yes. The vulnerability comes from JavaScript’s object model, not from the runtime. Any runtime that runs a vulnerable merge function on untrusted input is exposed, although the available gadgets differ.

Is Object.freeze(Object.prototype) a complete fix?
It stops writes to the base prototype, but it does not protect other prototypes such as Array.prototype or your own class prototypes, and it can break legacy libraries. Treat it as defense in depth on top of input validation.

How do I know if a pollution finding is exploitable?
Pollution confirms the source. Exploitability depends on whether a reachable gadget reads the polluted property. Without a gadget, the impact is often limited to denial of service, which is still worth fixing.

Key Takeaways for JavaScript Teams

Prototype pollution needs two things: a function that writes attacker-controlled keys into objects, and a gadget that trusts inherited properties. Remove either one and the chain breaks. Validate input against a strict schema, use Map or Object.create(null) for dynamic keys, keep lodash and jQuery current, and test with constructor.prototype as well as __proto__. If an authorization bug clears on restart and then comes back, check your merge helpers before anything else.