Prototype Pollution: The JavaScript Vulnerability That Hides in Plain Sight 🧬☠️
Prototype Pollution: The JavaScript Vulnerability That Hides in Plain Sight 🧬☠️
Imagine this: Your Node.js app is humming along. You've got input validation, parameterized queries, helmet.js headers — you've done everything right. Then a security researcher drops a report: one crafted JSON payload lets any anonymous user bypass your admin check.
The culprit? A single _.merge() call. Three lines of totally normal-looking code.
Welcome to Prototype Pollution — one of the sneakiest vulnerabilities in the JavaScript ecosystem, and one that has bitten lodash, jQuery, and dozens of other popular libraries. Let's understand how it works and how to stop it cold. 🎯
First: How JavaScript Prototypes Actually Work
Every object in JavaScript inherits from Object.prototype. When you access a property that doesn't exist on an object, JavaScript walks up the prototype chain looking for it.
const user = { name: "Alice" };
// These work because they're inherited from Object.prototype
console.log(user.toString()); // "[object Object]"
console.log(user.hasOwnProperty("name")); // true
// You can access the prototype directly:
console.log(user.__proto__ === Object.prototype); // true
This prototype chain is powerful — and also the attack surface. If you can write to Object.prototype, every object in your entire application inherits that pollution. Not just the object you're modifying. Every. Single. Object.
The Attack: Polluting the Global Prototype
Here's what prototype pollution looks like in practice:
// A totally innocent-looking deep merge function
function deepMerge(target, source) {
for (const key of Object.keys(source)) {
if (typeof source[key] === "object" && source[key] !== null) {
if (!target[key]) target[key] = {};
deepMerge(target[key], source[key]); // 💀 recursive merge, no key sanitization
} else {
target[key] = source[key]; // 💀 blindly assigns any key, including __proto__
}
}
return target;
}
// Normal usage — totally fine
const config = deepMerge({}, { timeout: 3000, retries: 2 });
// Attacker sends this JSON payload:
const evilPayload = JSON.parse('{"__proto__": {"isAdmin": true}}');
// This looks harmless but it's not:
deepMerge({}, evilPayload);
// Now EVERY object in the app is "admin":
const newUser = {};
console.log(newUser.isAdmin); // true — even though we never set it!
// Your auth check is now broken globally:
if (request.user.isAdmin) {
// This passes for EVERY user, including anonymous ones
grantAdminAccess();
}
The __proto__ key is the magic weapon. When your merge function hits target["__proto__"], it's not creating a property called __proto__ — it's directly modifying the prototype of the target object's class. And since most objects inherit from Object.prototype, you've just modified the global ancestor of everything.
Real-World Impact: It's Not Just "Setting Properties"
Prototype pollution can go far beyond adding a boolean flag. Depending on how your app uses inherited properties, attackers can:
- Bypass authentication —
isAdmin,isAuthenticated,roleset globally - Trigger RCE via template engines — libraries like Handlebars and Pug read from inherited properties during template compilation
- Cause denial of service — set
__proto__.length = -1and watch your array operations crash - Escalate to full Remote Code Execution — in specific Node.js + template engine combos, this is a documented attack chain
The lodash _.merge() vulnerability (CVE-2019-10744) affected millions of projects and is still one of the most commonly found vulns in dependency audits today.
How to Fix It: Defense in Depth
You don't need to rewrite everything. A few focused changes give you strong protection:
// Fix 1: Sanitize keys before merging — block prototype-touching keys
function safeMerge(target, source) {
const BLOCKED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
for (const key of Object.keys(source)) {
if (BLOCKED_KEYS.has(key)) {
console.warn(`Blocked dangerous key: ${key}`);
continue; // ✅ skip it, don't process it
}
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;
}
// Fix 2: Use Object.create(null) for dictionaries that hold arbitrary user data
// These objects have NO prototype — they can't be polluted up the chain
const safeStore = Object.create(null);
safeStore["__proto__"] = "harmless string"; // just a normal key now
// Fix 3: Validate with JSON Schema before any deep merge
import Ajv from "ajv";
const ajv = new Ajv();
const schema = {
type: "object",
additionalProperties: false, // reject unknown keys
properties: {
timeout: { type: "number" },
retries: { type: "number" }
}
};
const validate = ajv.compile(schema);
if (!validate(userInput)) {
throw new Error("Invalid input shape — rejected before merge");
}
Bonus fix: Use Object.freeze(Object.prototype) in non-production environments to make prototype pollution throw immediately during development and testing. You'll catch it before it ships.
Quick Audit Checklist for Your Codebase
Run through these and you'll catch 90% of the risk:
- Audit deep merge utilities — any recursive object merge touching
source[key]is a candidate. Check lodash version (< 4.17.21is vulnerable). npm audit— run it now and look for "Prototype Pollution" in the output. It's shockingly common in transitive dependencies.- Check template engine versions — Handlebars
< 4.7.7, Pug< 2.0.4, and others had prototype pollution bugs. - Search for
__proto__in your own code — if you're using it intentionally, make sure it's not coming from user input. - Use
hasOwnPropertychecks before trusting any inherited property as an authorization signal.
The Bottom Line
Prototype pollution is sneaky because it doesn't look dangerous. A deepMerge helper, a config loader, a query string parser — these feel mundane. But if they accept untrusted input and recurse into nested objects without key sanitization, you've got a vulnerability hiding in perfectly readable code.
The fix is also not scary: block the magic keys (__proto__, constructor, prototype), use schema validation at your boundaries, reach for Object.create(null) for untrusted data stores, and keep your deep-merge dependencies up to date.
Your prototype is your foundation. Don't let attackers build on it.
Found a prototype pollution chain in the wild or have war stories from dealing with lodash CVEs? I'd love to hear about it — hit me up on GitHub or connect on LinkedIn. And if this saved you from a bad day, share it with your team — there's a good chance someone on your squad is shipping a vulnerable deep merge right now. 🛡️