Prototype Pollution
Block prototype pollution attacks from untrusted JSON - unsafe lodash.merge, Object.assign, and recursive parsers can poison every object in your process.
Search across all documentation pages
Block prototype pollution attacks from untrusted JSON - unsafe lodash.merge, Object.assign, and recursive parsers can poison every object in your process.
Quick-reference recipe card - copy-paste ready.
import { z } from "zod";
// Prefer parse over merge for request shaping
const SettingsSchema = z.object({
theme: z.enum(["light", "dark"]).optional(),
notifications: z.boolean().optional(),
});
function updateSettings(body: unknown) {
return SettingsSchema.parse(body);
}When to reach for this:
_.merge, _.defaultsDeep, or custom deep assign on req.body.lodash CVEs for prototype pollution.?a[b][__proto__][x]=1).import { z } from "zod";
// VULNERABLE - do not do this on untrusted input
function vulnerableMerge(target: Record<string, unknown>, source: unknown) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const _ = require("lodash");
return _.merge(target, source);
}
// SAFE - explicit schema, no prototype keys
const PatchSchema = z.object({
displayName: z.string().min(1).max(80).optional(),
locale: z.string().regex(/^[a-z]{2}$/).optional(),
}).strict();
function safePatch(body: unknown) {
return PatchSchema.parse(body);
}
// SAFE - shallow assign with blocklist if you must merge
function safeShallowAssign<T extends Record<string, unknown>>(
target: T,
source: unknown
): T {
if (typeof source !== "object" || source === null || Array.isArray(source)) {
throw new Error("Invalid source");
}
for (const key of Object.keys(source)) {
if (["__proto__", "constructor", "prototype"].includes(key)) continue;
target[key as keyof T] = (source as Record<string, unknown>)[key] as T[keyof T];
}
return target;
}
// Demonstration: pollution attempt fails Zod strict()
const attack = JSON.parse('{"displayName":"Ada","__proto__":{"isAdmin":true}}');
safePatch(attack); // throws - unknown key in strict modeWhat this demonstrates:
_.merge recursively copies __proto__ keys onto prototypes in vulnerable versions..strict() rejects unknown keys including __proto__.JSON.parse itself is fine - the danger is where you assign parsed data.Object.prototype.{"__proto__": {"polluted": true}} or {"constructor": {"prototype": {"polluted": true}}}.Object.prototype.polluted = true.{} objects may then appear to have polluted - affecting auth checks, if (obj.isAdmin), etc.| API | Risk |
|---|---|
_.merge, _.defaultsDeep | Historical CVEs; avoid on user JSON |
Object.assign(target, userInput) | Copies __proto__ key in some engines/paths |
| Custom recursive parsers | Must skip __proto__, constructor, prototype |
flat query parsers | qs with allowPrototypes: false |
npm ls lodash
npm auditstructuredClone of validated DTOs over merge._.mergeWith and reject dangerous keys in customizer.if (Object.prototype.hasOwnProperty("isAdmin")) {
throw new Error("Prototype pollution detected");
}merge(, defaultsDeep, Object.assign(req.body.req.body into Mongoose/Prisma create - mass assignment + pollution. Fix: Zod parse to DTO first.allowPrototypes: true (default in old configs) - query pollution. Fix: allowPrototypes: false.additionalProperties: true - accepts __proto__. Fix: additionalProperties: false or Zod strict.__proto__ - constructor.prototype path also works. Fix: block all three keys.| Alternative | Use When | Don't Use When |
|---|---|---|
| Zod parse | HTTP body/query shaping | Need deep patch of nested config files (use schema per path) |
| JSON Schema strict | Fastify routes | Dynamic arbitrary keys |
| Structured clone of validated tree | Copying safe objects | Before validation |
| Map instead of object | Arbitrary user keys | JSON API still uses objects at boundary |
Yes. The vulnerability is assigning parsed values into existing objects via unsafe merge utilities.
Freezing Object.prototype is possible in theory but breaks many libraries. Prefer input validation.
JSON Schema with additionalProperties: false rejects extra keys. Same as Zod strict.
If code checks if (user.isAdmin) and attacker pollutes Object.prototype.isAdmin = true, yes. Rare but catastrophic.
Run @lavamoat/lavapack or dedicated prototype pollution test vectors against your merge utilities.
Any deep-set library on untrusted paths is risky. Allowlist path segments.
Validate variables with Zod or graphql-constraint-directive before resolvers merge into objects.
ramda merge has similar concerns. Schema validation at the boundary is the real fix.
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.
Reviewed by Chris St. John·Last updated Jul 16, 2026