Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 079 / 100

Prototype pollution for JavaScript & TypeScript developers

A __proto__ key can poison every object.

Expona Admin6 min read

Prototype pollution

You've built a clean REST API that merges user-provided configuration with your defaults, or you're processing a JSON file from an untrusted source. Then suddenly, every object in your entire application starts behaving strangely—methods fail, properties that should be undefined exist everywhere. That's prototype pollution, a security vulnerability where attackers manipulate the __proto__, constructor, or prototype properties to poison the prototype chain and affect every object in the application.

I've seen this happen in production when a team trusted JSON merge operations too much. By the end of this article, you'll understand exactly why it happens and how to defend against it.

Deep merge of user JSON is dangerous

The most common entry point is a recursive merge function that accepts user input. Picture this scenario: you're building a SaaS platform where customers can override default settings. You write a helpful merge utility:

typescript
function deepMerge(target: any, source: any): any {
  for (const key in source) {
    if (source[key] && typeof source[key] === 'object') {
      target[key] = deepMerge(target[key] || {}, source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

const defaults = { debug: false, timeout: 5000 };
const userConfig = JSON.parse(userInput);
const config = deepMerge(defaults, userConfig);

Now an attacker sends this JSON:

typescript
{
  "__proto__": {
    "isAdmin": true,
    "bypassAuth": true
  }
}

When your code processes this with deepMerge, it traverses into the __proto__ key and modifies Object.prototype. Suddenly, every plain object in your app—including new user instances, database models, and request objects—inherits isAdmin: true and bypassAuth: true. Your entire permission system breaks.

The danger lies in two things: first, the for...in loop enumerates inherited properties including __proto__; second, assigning to target[key] when key is __proto__ actually modifies the prototype chain, not a regular property. This is why I call it pollution—it's not localized to one object, it spreads to the entire inheritance tree.

Block __proto__, constructor and prototype keys

The most direct fix is to reject or skip these dangerous keys entirely. You have several variations of the same attack surface:

  • __proto__: Direct access to the prototype object
  • constructor: Can lead to constructor.prototype manipulation
  • prototype: If iterating deeply through constructor chains

Here's a hardened merge function:

typescript
function deepMerge(target: any, source: any): any {
  const dangerousKeys = ['__proto__', 'constructor', 'prototype'];

  for (const key in source) {
    if (dangerousKeys.includes(key)) {
      continue; // Skip dangerous keys
    }

    if (source.hasOwnProperty(key)) {
      if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
        target[key] = deepMerge(target[key] || {}, source[key]);
      } else {
        target[key] = source[key];
      }
    }
  }
  return target;
}

Notice I also added hasOwnProperty check—this ensures we only merge direct properties of the source object, not inherited ones. Combined with the dangerous key blocklist, this stops the attack at the source.

However, this approach has a limitation: it's a blacklist, and blacklists are fragile. New JavaScript features or overlooked edge cases could bypass it. I consider this a necessary layer of defense, but never the only one.

Use Object.create(null) for dictionaries

A more robust approach is to use objects without a prototype at all. When you create an object with Object.create(null), it has no prototype chain—it inherits nothing, not even the basic Object methods. This means __proto__ assignment can't poison anything because there's no prototype to pollute.

If your use case is storing key-value pairs from untrusted sources, especially configuration or user-defined fields, prefer Object.create(null):

typescript
function safeConfig(userInput: string): Record {
  // Create an object with no prototype chain
  const config = Object.create(null);
  const parsed = JSON.parse(userInput);

  for (const key in parsed) {
    if (parsed.hasOwnProperty(key)) {
      config[key] = parsed[key];
    }
  }

  return config;
}

const cfg = safeConfig('{"__proto__": {"isAdmin": true}, "timeout": 5000}');
console.log(cfg.isAdmin); // undefined
console.log(cfg.toString); // undefined (no Object.prototype methods)
console.log(cfg.timeout); // 5000

With Object.create(null), even a direct assignment to __proto__ can't pollute Object.prototype because the object has no prototype. The downside is you lose Object methods like toString, hasOwnProperty, etc., so this works best for pure data dictionaries.

In a real system, I'd use this pattern for storing customer metadata, feature flags per user, or any configuration that comes from JSON. For objects that need normal prototype behavior (domain models, service classes), I'd keep them separate and never merge user data into them directly.

Prefer Map for user-controlled keys

The cleanest solution when you're dealing with dynamic, user-controlled keys is to use JavaScript's Map. Maps store key-value pairs separate from the prototype chain, so pollution is impossible:

typescript
function loadUserPreferences(jsonString: string): Map {
  const preferences = new Map();
  const parsed = JSON.parse(jsonString);

  for (const [key, value] of Object.entries(parsed)) {
    preferences.set(key, value);
  }

  return preferences;
}

const prefs = loadUserPreferences('{"__proto__": {"isAdmin": true}, "theme": "dark"}');
console.log(prefs.get('__proto__')); // undefined
console.log(prefs.get('theme')); // "dark"
console.log(prefs.has('isAdmin')); // false
// Object.prototype is untouched

Why this works: Maps are built-in collections with their own internal slot for storing entries. They don't use object properties for storage, so the prototype chain is irrelevant. There's no way for a key in a Map to pollute global prototypes.

I recommend Maps for any scenario where keys come from users or external systems: API parameters, database query filters, feature flags per customer, or permission definitions. It's a small API shift—map.get(key) instead of obj[key]—but it buys you genuine safety without ongoing vigilance.

Common mistakes

  • Relying only on blacklists: Blocking __proto__, constructor, and prototype is good practice, but libraries and frameworks evolve. Always combine it with whitelist approaches (allow only known keys) or safer data structures.
  • Merging directly into application objects: Never merge user JSON into domain models or singletons. Merge into isolated, purpose-built containers first, then copy safe values to your actual objects.
  • Forgetting nested objects: If you have {"settings": {"__proto__": {"isAdmin": true}}}, a recursive merge without guards will still pollute when it recurses. Your defense must apply at every recursion level.
  • Using regular objects for config dictionaries: If you're storing config or metadata, using Object.create(null) or Map from the start is simpler than trying to sanitize every input. It's a design choice, not an afterthought.
  • Testing only happy paths: Add tests with `__proto__`, `constructor`, and `prototype` keys to your merge/load functions. If the test passes, you know the attack vector is blocked.

Key takeaways

  • Prototype pollution happens when recursive merge operations allow assignment to __proto__, constructor, or prototype keys, poisoning the entire prototype chain and affecting all objects in the application.
  • Always block dangerous keys in merge functions and validate at every recursion level. Use hasOwnProperty to skip inherited properties.
  • For untrusted key-value data, prefer Object.create(null) to eliminate the prototype chain entirely, preventing any form of prototype pollution.
  • When working with dynamic, user-controlled keys, use Map instead of plain objects. Maps store data in isolated internal slots, making prototype pollution impossible.
  • Combine multiple defenses: blacklists, whitelists, safer data structures, and thorough testing. No single technique is complete on its own.
  • Treat user-provided JSON with deep suspicion. If it can be merged, it can be weaponized unless you enforce strict boundaries.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles