0x55aa
Back to Blog

Prototype Pollution: The JavaScript Vulnerability Hiding in Your node_modules ๐Ÿงฌ

|
5 min read

Prototype Pollution: The JavaScript Vulnerability Hiding in Your node_modules ๐Ÿงฌ

Congratulations, you sanitized your inputs. You parameterized your queries. You're feeling pretty secure. Then some researcher drops a CVE on a library you've used since 2019 and suddenly your app is compromised without a single line of YOUR code being wrong.

Welcome to Prototype Pollution โ€” the vulnerability that turns JavaScript's most "interesting" feature against you.

JavaScript Prototypes in 60 Seconds โฑ๏ธ

In JavaScript, every object has a hidden ancestor called Object.prototype. It's the grandparent of literally everything.

const dog = { name: "Rex" };

// These look like they belong to `dog`, but they come from Object.prototype:
dog.toString();     // "[object Object]"
dog.hasOwnProperty("name"); // true

// You can prove it:
dog.__proto__ === Object.prototype; // true

Here's the scary part: prototype properties are shared across ALL objects of the same type. If you modify Object.prototype, you modify every single object in your entire application. Every. Single. One.

The Attack: Poisoning the Well ๐Ÿงช

Prototype pollution happens when an attacker can inject properties into Object.prototype through seemingly innocent operations โ€” like merging user-controlled JSON into an object.

Here's the classic vulnerable pattern:

// A common "deep merge" helper โ€” looks harmless, right?
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]; // ๐Ÿ’€ No key validation!
    }
  }
  return target;
}

// Normal usage:
const config = merge({}, { theme: "dark" });

// Attacker sends this JSON payload:
const maliciousPayload = JSON.parse('{"__proto__": {"isAdmin": true}}');
merge({}, maliciousPayload);

// Now check ANY object in your app:
const user = {};
console.log(user.isAdmin); // true โ€” even though we never set it!

// Worse:
if (user.isAdmin) {
  grantRootAccess(); // ๐Ÿ”ฅ
}

The attacker never touched your user object. They just poisoned the prototype, and now every plain object in your app thinks it's an admin. The attack surface is your entire JavaScript runtime.

Real-World Victims ๐Ÿ’€

This isn't theoretical. Here are actual CVEs from libraries you've almost certainly used:

  • lodash (CVE-2019-10744) โ€” _.merge(), _.mergeWith(), _.defaultsDeep() โ€” all vulnerable before 4.17.12. Lodash has 40 million weekly downloads.
  • jQuery (CVE-2019-11358) โ€” $.extend() with deep: true. Yes, that jQuery.
  • minimist (CVE-2020-7598) โ€” The tiny args parser used by webpack, mocha, and basically everything.
  • qs (CVE-2022-24999) โ€” The query string parser used by Express.

If your package-lock.json is more than a few months old, run npm audit right now. I'll wait.

How to Defend Against It ๐Ÿ›ก๏ธ

1. Freeze the prototype (nuclear option)

Object.freeze(Object.prototype);

// Now this silently fails (or throws in strict mode):
const evil = JSON.parse('{"__proto__": {"isAdmin": true}}');
Object.assign({}, evil); // prototype is frozen, property is ignored

This works but can break third-party code that legitimately extends prototypes (looking at you, some older polyfills).

2. Use Object.create(null) for data bags

// Instead of:
const config = {};

// Use:
const config = Object.create(null);
// config has NO prototype โ€” __proto__ is just a regular property now
config.__proto__ = "polluted"; // does nothing dangerous

3. Validate keys before merging

function safeMerge(target, source) {
  for (const key of Object.keys(source)) { // Object.keys() skips inherited props
    if (key === "__proto__" || key === "constructor" || key === "prototype") {
      continue; // ๐Ÿšซ Skip dangerous keys
    }
    if (typeof source[key] === "object" && source[key] !== null) {
      target[key] = safeMerge(target[key] || {}, source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

4. Use structuredClone() for deep copies

Modern Node.js (17+) and all modern browsers ship with structuredClone(). It does deep cloning without the prototype pollution risk:

const userConfig = structuredClone(JSON.parse(untrustedInput));
// __proto__ keys are ignored during structured clone

5. Keep dependencies updated and audited

npm audit                    # Find known vulnerabilities
npm audit fix                # Auto-fix what's fixable
npx snyk test                # More thorough commercial scanner (free tier available)

Set up npm audit in your CI pipeline. A dependency with a known CVE should fail the build, not sneak into production.

The Bigger Lesson ๐Ÿง 

Prototype pollution is a reminder that security isn't just about your code โ€” it's about your entire dependency tree. Every npm install is a trust decision. The average Node.js app has hundreds of transitive dependencies, and most developers couldn't name 10% of them.

This doesn't mean you should write everything from scratch (please don't). It means:

  1. Run npm audit on every CI build
  2. Use tools like Dependabot or Renovate to keep deps fresh automatically
  3. Prefer libraries with active maintenance and fast CVE response times
  4. When merging user-controlled data into objects, always sanitize keys

JavaScript's flexibility is its superpower and its Achilles' heel. Prototype pollution is what happens when that flexibility meets attacker-controlled input. Now you know it exists โ€” and knowing is half the battle.


Found this useful? Share it with a JavaScript dev who thinks npm install is always safe. You might save them from a very awkward security incident.

Hit me up on GitHub or drop a comment โ€” I'd love to hear if you've ever found prototype pollution in the wild.

Thanks for reading!

Back to all posts