Skip to main content
Published
Prototype pollutionSecurityJavaScript

Prototype pollution in JavaScript apps

Unsafe deep merges of untrusted JSON, __proto__ payloads, and safer patterns for frontend state and config merging.

Prototype pollution lets an attacker poison Object.prototype through unsafe merges of JSON. Every object then inherits unexpected properties. Frontend apps hit this when they deep-merge query params, config, or API payloads into plain objects.

Impact ranges from broken UI logic to gadget chains that become XSS in older utility stacks. Lodash merge incidents are the textbook case. The pattern still appears in homemade merge helpers.

How a frontend merge goes wrong

User-controlled JSON contains __proto__ or constructor.prototype. A recursive assign copies it onto the shared prototype.

  • Do not deep-merge untrusted JSON into app state blindly.
  • Keep dependencies updated when merge utilities are involved.
  • Use Maps or null-prototype objects for untrusted key spaces.
  • Prefer structured cloning or explicit field picking over recursive merge.
lib/unsafeMerge.tsts
// Vulnerable sketch
export function unsafeMerge(target: any, source: any) {
  for (const key of Object.keys(source)) {
    if (source[key] && typeof source[key] === "object") {
      target[key] ??= {};
      unsafeMerge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}
 
// Payload idea after JSON.parse:
// { "__proto__": { "isAdmin": true } }
Recursive merge without key checks.
lib/safeMerge.tsts
const BLOCKED = new Set(["__proto__", "prototype", "constructor"]);
 
function isPlainObject(value: unknown): value is Record<string, unknown> {
  return (
    typeof value === "object" &&
    value !== null &&
    !Array.isArray(value) &&
    Object.getPrototypeOf(value) === Object.prototype
  );
}
 
export function safeMerge(
  target: Record<string, unknown>,
  source: Record<string, unknown>
) {
  for (const [key, value] of Object.entries(source)) {
    if (BLOCKED.has(key)) continue;
 
    if (isPlainObject(value)) {
      const current = target[key];
      const next = isPlainObject(current)
        ? current
        : Object.create(null);
 
      target[key] = safeMerge(next, value);
    } else {
      target[key] = value;
    }
  }
 
  return target;
}
 
// Even better for untrusted key spaces: use Map, or skip deep merge entirely.
Reject prototype keys. Prefer Object.create(null) for dictionaries.

More writing

Availability

Open to opportunities

Open to senior product engineer roles with real ownership across the stack. Security-minded by default.

UK-based · Remote / hybrid · Permanent or contract