We Can't Harden Node.js Against Prototype Pollution

Fudgel1 pts0 comments

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

Adventures in Nodeland

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:<br>Object.prototype.shell = 'node'<br>Object.prototype.env = { NODE_OPTIONS: '--inspect-brk=0.0.0.0:1337' }

// step 1, the "gadget" in core:<br>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...

prototype core object node harden against

Related Articles