Adventures in Nodeland logo

Adventures in Nodeland

Archives
Subscribe
July 19, 2026

No, We Can't Harden Node.js Against Prototype Pollution

"Prototype pollution isn't a Node or library issue, it's on the app's guard to thwart code vulnerabilities!"

Every few weeks, a security report lands that follows the same template. Someone has found a new gadget: a spot where code reads a property it expected to be absent and does something dangerous with it. child_process picking up a shell it was never passed. A router honouring an option out of nowhere. An ORM resolving a field it should not. Sometimes the gadget is in Node.js core; more often it is in a library three levels down your dependency tree. It does not much matter which. The write-up is careful, the proof-of-concept works, and the ask at the bottom is always the same: please harden this so it can't happen.

My answer is always the same too, and people do not love it.

You cannot harden against this. Not because the maintainer is lazy, and not because the gadget isn't real. Because every one of these reports has the same precondition buried in step zero: the application already has a prototype pollution. And once that is true, the gadget you found is not the vulnerability. It is a symptom. There are infinitely many more behind it: in core, in that library, in every other library you depend. Hardening any single one of them closes nothing.

Read the precondition

Here is the shape of the report, distilled:

// step 0, assumed to already exist somewhere in the app:
Object.prototype.shell = 'node'
Object.prototype.env = { NODE_OPTIONS: '--inspect-brk=0.0.0.0:1337' }

// step 1, the "gadget" in core:
execSync('echo "hi"') // now honours a polluted NODE_OPTIONS → RCE

The reporter treats step 1 as the bug and asks us to fix execSync. But look at step 0. The attacker already has write access to Object.prototype. They have already won. execSync is just the exit they happened to pick this week.

Ask yourself what "harden execSync" even buys you. Suppose core null-prototypes its options object and ignores inherited shell and env. Great. The same attacker moves to fetch, whose RequestInit inherits method and body. Then to vm and contextExtensions. Then out of core entirely — to the config merge in your web framework, the options bag in your database driver, the lodash.merge in something you have never opened. The Silent Spring paper catalogued eleven universal gadgets in core alone, and that was people working to a paper deadline, not the ceiling. Every function anywhere in your dependency tree that reads an options object is a candidate. You are not patching a hole. You are playing whack-a-mole against the whole ecosystem, and the language is spotting the attacker every point.

Why you can't win the whack-a-mole

This is the part that makes it structural rather than a backlog we are behind on.

JavaScript reads through the prototype chain everywhere, and it dispatches to prototype slots through syntax, not just method calls. Destructuring, spread, for...of, await reading .then off a thenable, template literals, regular expressions — all of them reach into user-mutable slots. And every object literal {} that core creates still inherits from Object.prototype. The moment core writes if (!opts.shell) or opts.timeout ?? 30000, that lookup walks straight into whatever the attacker planted.

To actually harden core against a polluted prototype, you would have to { __proto__: null } every object core allocates, avoid every syntactic construct that touches a prototype slot, and force every dependency to do the same. That is not a hardening pass. That is a different language.

This is exactly the wall the primordials effort ran into.

Primordials are integrity, not security

Primordials are references to the original built-ins (Array.prototype.push, Object.keys) that are captured at startup before user code runs, so core can call ArrayPrototypePush(arr, x) instead of arr.push(x) and keep working even if someone monkey-patches the global. That is genuinely useful. A test runner should not fall over because a dependency reassigned Array.prototype.push. I still think that holds.

But notice what it protects: Node not breaking. Not your application being safe. Primordials give you a pristine push; they do nothing about the fact that the options object you just read still has a polluted prototype in its chain. Joyee Cheung put it exactly right in the TSC thread: even 100% primordials coverage would not close all the holes, because of how ECMAScript is specified. A fence with a hole in it is not much better than no fence — that was Geoffrey Booth's line, and he was right.

So when a report asks us to "add primordials here to fix the gadget," it is asking the wrong mechanism to do a job it was never built for.

This is why it isn't a vulnerability

Benjamin Gruenbaum said it plainly: "Any prototype pollution in Node.js core is not accepted as a vulnerability in our security/bug bounty program."

That is not the project shrugging. It is the project being honest about where the boundary is. If you read my last post on CVE-2026-48931, this is the same argument from the other side: a threat model is a set of promises, and promising robustness the language semantics will not let you deliver is worse than not promising it, because people build on the promise and stop guarding their own edge. A gadget that requires an existing prototype pollution is a report about the application that polluted the prototype. It is not a report about Node, and it is not a report about whichever library happened to contain the gadget this time.

The fix lives at the boundary, and it always did

None of this means prototype pollution is harmless. It is one of the nastiest classes we have, precisely because a single write to Object.prototype grants ambient authority over nearly every object in the process. But the place to stop it is the source — the exact point untrusted bytes become objects.

JSON.parse('{"__proto__": {"admin": true}}') does not pollute anything on its own; it creates a boring own property named __proto__. The damage comes later, when a recursive merge or an Object.assign promotes that key onto the real prototype chain, sliding past validators that never saw a real property there. This is the vector Eran Hammer described years ago, and it is why we run secure-json-parse on every request body in Fastify by default:

const parse = require('secure-json-parse')

parse(body, { protoAction: 'error', constructorAction: 'error' })
// throws on __proto__ / constructor.prototype, before the object exists

error to reject, remove to strip, ignore if you really know what you are doing. The default is error, and it should be. Catch the write at the boundary and there is no step zero for the next gadget report to stand on. Then belt and braces: freeze what you can, prefer Map over object-as-dictionary for untrusted keys, reach for null-prototype objects when you hold attacker-controlled data.

Why this matters to me

I have spent a decade trying to keep Node.js viable for serious production load, and a lot of that is drawing boundaries in the right place. The reports will keep coming, and each one will be technically correct and practically unactionable, because they are all asking core to compensate for a bug that lives upstream in an application.

So here is the uncomfortable version of the lesson, the one worth keeping. The runtime cannot protect you from a class of bug the language itself makes reachable. If an attacker can write to your prototypes, the game is already over — the gadget is just the scoreboard. Your job is to make sure they never get that write in the first place.

Guard your boundaries. Nobody downstream is going to do it for you.

Don't miss what's next. Subscribe to Adventures in Nodeland:
Older → CVE-2026-48931 Shouldn't Have Been a CVE
Share this email:
Share on Twitter Share on LinkedIn
GitHub
Twitter
YouTube
LinkedIn