Prototype Pollution: The JavaScript Vulnerability That Hides in Plain Sight 🧬
Prototype Pollution: The JavaScript Vulnerability That Hides in Plain Sight 🧬
Imagine a hacker walks into your app, doesn't touch your database, doesn't inject any scripts, and yet somehow makes your entire Node.js server behave like their personal puppet. No explosions. No drama. Just a quietly poisoned object that infects every single thing in your app.
That's prototype pollution — the JavaScript security bug that reads like a villain origin story.
First: A Two-Minute JavaScript Refresher 🧠
JavaScript uses prototypal inheritance. Every object inherits properties from Object.prototype. Think of it like DNA — your object gets its own properties, but also inherits a bunch of built-ins from the ancestor.
const user = { name: "Alice" };
// These come from Object.prototype, not from `user`:
user.toString(); // works!
user.hasOwnProperty("name"); // works!
So far so good. But here's the spicy part: Object.prototype is a shared ancestor for ALL objects. Modify it, and you've touched every single object in your runtime. Every. Single. One.
That's the weapon prototype pollution gives attackers.
What Prototype Pollution Actually Looks Like 💀
The attack works by sneaking a __proto__ key into data that gets merged into objects. Here's a toy example of a vulnerable "deep merge" utility — the kind that lives in countless npm packages and home-grown utilities:
function deepMerge(target, source) {
for (const key in source) {
if (typeof source[key] === "object" && source[key] !== null) {
if (!target[key]) target[key] = {};
deepMerge(target[key], source[key]);
} else {
target[key] = source[key]; // 💣 No key validation!
}
}
return target;
}
// Attacker sends this JSON payload:
const maliciousPayload = JSON.parse('{"__proto__": {"isAdmin": true}}');
deepMerge({}, maliciousPayload);
// Now EVERY object in your app has isAdmin = true
const freshObject = {};
console.log(freshObject.isAdmin); // true 😱
That deepMerge({}, maliciousPayload) line just poisoned Object.prototype.isAdmin for the entire process. Now when your auth check does if (user.isAdmin), a brand new empty object passes the check. Your security logic is now meaningless.
Real-World Impact: Not Just a Theory 🔥
This isn't academic. Prototype pollution has been found in some of the most popular npm packages:
- lodash (CVE-2019-10744) —
_.merge()was vulnerable. Lodash has hundreds of millions of weekly downloads. - jQuery (CVE-2019-11358) —
$.extend()with deep mode was pollutable. - hoek (used by the Hapi.js ecosystem) — also patched a prototype pollution bug.
The attack surface is huge because "deep merge user-provided data into an object" is something apps do constantly — config loading, API request handling, query string parsing. Everywhere JSON from the outside world meets your object utilities is a potential entry point.
How to Actually Fix It 🛡️
1. Validate keys before merging
The quick fix: skip any key that is __proto__, constructor, or prototype.
function safeMerge(target, source) {
for (const key in source) {
// Block the dangerous keys
if (key === "__proto__" || key === "constructor" || key === "prototype") {
continue;
}
if (typeof source[key] === "object" && source[key] !== null) {
if (!target[key]) target[key] = {};
safeMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
2. Use Object.create(null) for data-only objects
Objects created with Object.create(null) have no prototype at all. They're pure data containers with zero inherited properties — and poisoning Object.prototype doesn't affect them.
const safeConfig = Object.create(null);
safeConfig.debug = false;
// safeConfig has no __proto__, no toString, nothing inherited
This is great for lookup tables, config maps, or caches where you just need key/value storage.
3. Use Object.freeze() on Object.prototype
For extra paranoia in server-side code, you can freeze the prototype entirely:
Object.freeze(Object.prototype);
// Now any attempt to add properties to Object.prototype silently fails (or throws in strict mode)
Caveat: this can break poorly written third-party libraries, so test before deploying.
4. Keep your dependencies updated
Seriously. npm audit exists for a reason. Run it. Fix the high-severity stuff. A prototype pollution bug in [email protected] won't haunt you if you're on 4.17.21.
A Quick Detection Trick 🔍
Want to check if your app is vulnerable? Add this early in your test suite or startup code:
// Canary check — run this BEFORE any user data is processed
const canary = {};
if (canary.polluted) {
console.error("🚨 Object.prototype has been polluted! Check your dependencies.");
process.exit(1);
}
You can also use --frozen-intrinsics flag in newer Node.js versions to lock down built-in objects at startup — it's still experimental but promising.
The Bigger Picture 🌍
Prototype pollution sits in a nasty category: it's indirect, invisible, and global. You don't get a stack trace pointing at the attacker's payload. You get weird auth bypasses, unexpected undefined values becoming true, and logs full of confused errors. By the time you notice, the damage is done.
The lesson? Never blindly merge user-controlled data into objects. Treat every key in an incoming JSON payload as potentially adversarial. Your deepMerge util from 2018 might be the skeleton in your codebase's closet.
Stay paranoid. Run npm audit. And maybe go check that internal merge helper you wrote three years ago and forgot about. 👀
Found a prototype pollution bug in a real project? Or have a war story about a wild JavaScript vulnerability? Drop it in the comments or hit me up on socials — I'd love to hear it!
- Twitter/X: @kpanuragh
- GitHub: github.com/kpanuragh