Never merge user-controlled objects into application objects using Object.assign, spread, or deep-merge without validation. Prototype pollution lets an attacker inject __proto__ properties that modify the behavior of every object in your application — enabling denial of service, authentication bypass, or remote code execution.
Why This Matters
Prototype pollution allows an attacker to inject properties into Object.prototype via __proto__, which then appear on every object in the application. This can bypass authentication checks (if user.isAdmin reads from the polluted prototype), cause denial of service (crashing template engines), or achieve remote code execution via gadget chains.
JavaScript objects inherit from Object.prototype. If an attacker can set a property on Object.prototype (via __proto__, constructor.prototype, or similar), that property appears on every object in the application.
Prototype pollution occurs when user-controlled data is merged into application objects without filtering dangerous keys. Common vectors include:
Object.assign(target, userInput) where userInput contains __proto__
Deep-merge libraries that recursively copy properties without checking for prototype keys
JSON.parse() output passed directly to merge operations (JSON can contain __proto__ keys)
The impact ranges from denial of service (crashing template engines like Handlebars or Pug) to authentication bypass (polluting isAdmin or role properties) to remote code execution (via gadget chains in libraries like lodash or express).
The rule
Never merge user-controlled objects into application objects without filtering __proto__, constructor, and prototype keys. Use Object.create(null) for lookup tables. Prefer allowlist-based property copying over blocklist-based filtering.
Bad example
// Prototype pollution via Object.assignapp.post("/settings", (req, res) => { const defaults = { theme: "light", language: "en" }; const settings = Object.assign(defaults, req.body); // Attacker sends: { "__proto__": { "isAdmin": true } } // Now EVERY object has isAdmin === true});// Deep merge without prototype filteringimport merge from "lodash.merge";const config = merge({}, defaultConfig, userConfig);// userConfig.__proto__.polluted = true
Good example
// Allowlist-based property copyingapp.post("/settings", (req, res) => { const allowedKeys = ["theme", "language", "timezone"] as const; const settings: Record<string, string> = { theme: "light", language: "en" }; for (const key of allowedKeys) { if (req.body[key] !== undefined) { settings[key] = String(req.body[key]); } }});// Zod schema validation strips unknown propertiesimport { z } from "zod";const settingsSchema = z.object({ theme: z.enum(["light", "dark"]).default("light"), language: z.string().max(5).default("en"),});app.post("/settings", (req, res) => { const settings = settingsSchema.parse(req.body); // Only validated, known properties exist});