Prototype Pollution: The JavaScript Vulnerability Hiding in Your Dependencies 🧬
Picture this: you've done everything right. Parameterized SQL queries, escaped HTML output, added rate limiting, enabled MFA. Your app is a fortress.
Then an attacker sends a single crafted JSON payload — and suddenly every object in your Node.js process has a brand-new property they injected. Your isAdmin check passes. Your auth middleware skips. Your logs go silent.
Welcome to prototype pollution: the vulnerability that sounds made-up until it absolutely ruins your day.
JavaScript's Dirty Little Secret 🤫
To understand prototype pollution, you need to remember one fundamental truth about JavaScript: almost everything inherits from Object.prototype.
const user = { name: "Alice" };
// These are all inherited from Object.prototype:
user.toString();
user.hasOwnProperty("name");
user.constructor;
Every plain object in JavaScript has a __proto__ property pointing to Object.prototype. This is the prototype chain — and it's also the attack surface.
Here's the horror show:
// Innocent-looking merge function (seen in THOUSANDS of real codebases)
function merge(target, source) {
for (let key in source) {
if (typeof source[key] === "object") {
target[key] = merge(target[key] || {}, source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
// Attacker sends this JSON payload:
const maliciousPayload = JSON.parse('{"__proto__": {"isAdmin": true}}');
const config = {};
merge(config, maliciousPayload);
// Now EVERY object in the process inherits isAdmin:
const freshObject = {};
console.log(freshObject.isAdmin); // true 😱
// Including your auth check object:
const user = { name: "Bob", role: "viewer" };
console.log(user.isAdmin); // true 🔥
That merge function isn't fictional. Vulnerable versions of lodash, jquery, hoek, minimist, and dozens of other popular packages shipped exactly this bug. Combined, those packages have been downloaded hundreds of billions of times.
Why This Is Scarier Than It Looks 🎃
The insidious part isn't just injecting isAdmin. Prototype pollution enables:
1. Remote Code Execution via template engines
Many Node.js template engines (Handlebars, Pug, EJS) read configuration from object properties. Pollute the right property and you can inject server-side code.
2. Denial of Service
Pollute Object.prototype.toString or Object.prototype.valueOf and watch your app crash with cryptic TypeErrors everywhere.
3. Auth bypass without touching auth code
// Your auth middleware somewhere deep in the codebase:
function requireAdmin(req, res, next) {
if (req.user.isAdmin) { // <-- polluted! Always true now.
return next();
}
res.status(403).send("Forbidden");
}
The attacker doesn't need to find your auth code. They just need to find any vulnerable merge/clone somewhere in your app's request path.
How to Find It In Your Code 🔍
Red flags to grep for:
# Recursive merge/assign patterns that don't sanitize keys
grep -r "for.*in.*source\|Object.assign\|_.merge\|deepMerge" src/
# Direct __proto__ access (obvious, but worth checking)
grep -r "__proto__\|\[\"__proto__\"\]" src/
What vulnerable code looks like vs safe code:
// ❌ VULNERABLE — doesn't check key names
function deepMerge(target, source) {
for (const key in source) {
if (isObject(source[key])) {
target[key] = deepMerge(target[key] ?? {}, source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
// ✅ SAFE — explicitly blocks prototype-polluting keys
function deepMerge(target, source) {
for (const key of Object.keys(source)) { // Object.keys skips inherited props
if (key === "__proto__" || key === "constructor" || key === "prototype") {
continue; // Drop it like it's hot
}
if (isObject(source[key])) {
target[key] = deepMerge(target[key] ?? {}, source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
The fix is two lines. The two lines that were missing from lodash <4.17.5, causing CVE-2019-10744 — rated 9.8 Critical.
Defense in Depth: Multiple Layers 🛡️
1. Use Object.create(null) for data dictionaries
// Regular object — inherits from Object.prototype, pollutable
const cache = {};
// Null-prototype object — no prototype chain, unpollutable
const safeCache = Object.create(null);
safeCache.__proto__ = "whatever"; // Just a regular string key now, harmless
This is the nuclear option: if there's no prototype chain, there's nothing to pollute.
2. Freeze Object.prototype (app startup)
// Add this ONCE in your app entry point
Object.freeze(Object.prototype);
// Now any attempt to pollute throws a TypeError in strict mode
// or silently fails in sloppy mode — either way, attack fails
Side effect: any legitimate code that tries to extend Object.prototype (bad practice anyway) will also break. Most modern codebases handle this fine.
3. Validate JSON schema before merging
Never merge untrusted user input directly into objects. Use a JSON schema validator first:
import Ajv from "ajv";
const ajv = new Ajv();
const schema = {
type: "object",
properties: {
name: { type: "string" },
age: { type: "number" },
},
additionalProperties: false, // This line alone blocks __proto__ injection
};
const validate = ajv.compile(schema);
function safeMerge(target, userInput) {
if (!validate(userInput)) throw new Error("Invalid input");
return Object.assign(target, userInput);
}
additionalProperties: false is doing the heavy lifting here — it rejects any key not in your schema, including __proto__.
4. Keep dependencies patched
# Check for known prototype pollution CVEs right now:
npm audit
# Auto-fix where possible:
npm audit fix
# For specific packages, check the advisory database:
npx better-npm-audit audit
Prototype pollution CVEs show up in the wild regularly. npm audit in CI is non-negotiable.
Real-World Impact: It's Not Just Theory 📰
lodash (used by ~40% of npm packages at its peak): CVE-2019-10744, fixed in 4.17.5.
jQuery (yes, the old jQuery you forgot you still ship): CVE-2019-11358, fixed in 3.4.0.
minimist (the tiny argument parser): CVE-2020-7598, used to inject properties into server configuration.
The Snyk State of Open Source Security report listed prototype pollution as one of the top vulnerabilities found in JavaScript ecosystems for multiple consecutive years.
If your app has any npm install in its history, there's a non-trivial chance you were vulnerable at some point.
Quick Audit Checklist ✅
Before you close this tab, run these right now:
# 1. Check for known vulns in your current deps
npm audit
# 2. Find recursive merge patterns to review manually
grep -rn "for.*in\|deepMerge\|deepClone\|_.merge" --include="*.js" --include="*.ts" src/
# 3. Verify lodash is patched (if you use it)
npm list lodash | grep lodash
# 4. Look for Object.assign with user-controlled data
grep -rn "Object.assign.*req\.\|Object.assign.*body\|Object.assign.*params" src/
If npm audit comes back clean: great, but keep it in your CI pipeline. New CVEs drop every week.
The Mental Model to Take Away 🧠
Every time you write code that copies keys from user-supplied data into an object — ask yourself: "What happens if one of those keys is __proto__?"
If the answer is "it gets merged into the prototype chain of every object in my process," you have a problem.
The fix is always one of:
- Allowlist the keys you'll accept (schema validation)
- Blocklist the dangerous keys (
__proto__,constructor,prototype) - Use
Object.create(null)so there's no prototype to pollute - Freeze
Object.prototypeas a process-level guard
Pick two. Layers are good.
JavaScript is simultaneously the most deployed language on the planet and one of the most surprising when it comes to security edge cases. Prototype pollution is a perfect example: it exploits a fundamental language feature, hides in utility functions you didn't write, and can silently bypass security checks you carefully implemented.
The good news? It's completely preventable with a handful of defensive habits.
Now go run npm audit. I'll wait. 🎯
Found this useful? Connect with me on LinkedIn — I post regularly about web security and backend development.
More security deep-dives on GitHub — real examples, real fixes.
Stay paranoid. Stay patched. 🔐