Prototype Pollution
Block prototype pollution attacks from untrusted JSON - unsafe lodash.merge, Object.assign, and recursive parsers can poison every object in your process.
Recipe
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:
- Code uses
_.merge,_.defaultsDeep, or custom deep assign onreq.body. - Strange application-wide behavior after specific POST payloads.
- Dependency audit flags
lodashCVEs for prototype pollution. - Parsing nested query strings into objects (
?a[b][__proto__][x]=1).
Working Example
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:
_.mergerecursively copies__proto__keys onto prototypes in vulnerable versions.- Zod
.strict()rejects unknown keys including__proto__. - Shallow assign with blocklist is safer than deep merge for untrusted data.
JSON.parseitself is fine - the danger is where you assign parsed data.
Deep Dive
How It Works
- JavaScript objects inherit from
Object.prototype. - Attackers send
{"__proto__": {"polluted": true}}or{"constructor": {"prototype": {"polluted": true}}}. - Vulnerable merge walks keys and sets
Object.prototype.polluted = true. - All
{}objects may then appear to havepolluted- affecting auth checks,if (obj.isAdmin), etc.
High-Risk APIs
| 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 |
lodash Guidance
npm ls lodash
npm audit- Upgrade to patched lodash 4.17.21+.
- Prefer Zod or
structuredCloneof validated DTOs over merge. - If merge is required, use
_.mergeWithand reject dangerous keys in customizer.
Detection
if (Object.prototype.hasOwnProperty("isAdmin")) {
throw new Error("Prototype pollution detected");
}- Add CI security tests with known payloads from Snyk advisories.
- Code review grep:
merge(,defaultsDeep,Object.assign(req.body.
Gotchas
- Spreading
req.bodyinto Mongoose/Prisma create - mass assignment + pollution. Fix: Zod parse to DTO first. - qs
allowPrototypes: true(default in old configs) - query pollution. Fix:allowPrototypes: false. - JSON Schema
additionalProperties: true- accepts__proto__. Fix:additionalProperties: falseor Zod strict. - Server-side template config merge - admin UI saves JSON merged globally. Fix: schema validate admin input.
- Assuming frozen prod objects - pollution is process-wide. Fix: restart pods if pollution detected; fix merge site.
- Only checking
__proto__-constructor.prototypepath also works. Fix: block all three keys.
Alternatives
| 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 |
FAQs
Is JSON.parse safe?
Yes. The vulnerability is assigning parsed values into existing objects via unsafe merge utilities.
Does Object.freeze help?
Freezing Object.prototype is possible in theory but breaks many libraries. Prefer input validation.
Fastify and pollution?
JSON Schema with additionalProperties: false rejects extra keys. Same as Zod strict.
Can pollution bypass auth?
If code checks if (user.isAdmin) and attacker pollutes Object.prototype.isAdmin = true, yes. Rare but catastrophic.
How to test in CI?
Run @lavamoat/lavapack or dedicated prototype pollution test vectors against your merge utilities.
What about node-object-path?
Any deep-set library on untrusted paths is risky. Allowlist path segments.
GraphQL variables?
Validate variables with Zod or graphql-constraint-directive before resolvers merge into objects.
lodash alternative?
ramda merge has similar concerns. Schema validation at the boundary is the real fix.
Related
- Security Basics - safe assign example
- OWASP Top 10 for APIs - mass assignment
- Security Rules - input validation baseline
- Dependency Scanning - lodash CVE tracking
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.