A merge utility, hand-written so the team could control its semantics. It looked correct. It was not safe with untrusted input.
The vulnerable diff
// lib/merge.ts
export function merge(target: any, source: any): any {
for (const key of Object.keys(source)) {
// BUG: __proto__, constructor and prototype not blocked
if (typeof source[key] === "object" && source[key] !== null) {
target[key] = merge(target[key] ?? {}, source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
// elsewhere:
const config = merge(defaults, req.body);What is wrong
`Object.keys` does not return `__proto__` in normal traversal, but `for...in` and direct access do — and the bug here is in the *assignment*, not the iteration. When `source` is `{ "__proto__": { "isAdmin": true } }` and the merge recursion hits `target["__proto__"]`, it now writes onto the actual prototype chain of `target` — and since `target` is a plain object, that means `Object.prototype`. Every plain object in the process now reports `isAdmin === true`. This is CWE-1321.
The attack
# Configuration-update endpoint accepts a JSON merge
curl -X POST https://api.example.com/config \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"__proto__": {"isAdmin": true}}'
# After the merge, anywhere in the codebase:
const u = { name: "alice" };
u.isAdmin // -> true
# Object.prototype was mutated. Every plain object reports isAdmin = true
# until the process restarts.Real exploits often go further — set `polluted.shell = true` to trigger code paths that delegate to shell execution; set `polluted.toString = '...'` to break string conversion; set framework-specific keys (`router._renderError`, etc.) to gain RCE in some libraries that read those properties off any object.
Mesrai's review comment
mesraipilot · Bot · reviewed 2 min ago
[mesrai] [code-review] [Security] [CWE-1321] [high]
This merge does not block `__proto__`, `constructor`, or `prototype`
keys. With `source = { __proto__: { isAdmin: true } }` from untrusted
JSON, the recursion ends up writing onto `Object.prototype` and
poisoning every plain object in the process.
Two-part fix:
1. Skip dangerous keys explicitly.
2. Use `Object.create(null)` for the recursion target so the merged
object has no prototype chain to walk.
const DANGEROUS = new Set(["__proto__", "constructor", "prototype"]);
export function merge(target: any, source: any): any {
for (const key of Object.keys(source)) {
if (DANGEROUS.has(key)) continue;
if (isPlainObject(source[key])) {
target[key] = merge(target[key] ?? Object.create(null), source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
Or use `lodash.merge` ≥ 4.17.20 — which patched this class of bug.
Reference: HackerOne reports for lodash.merge CVE-2018-16487, CWE-1321The fix
// lib/merge.ts — fixed
const DANGEROUS = new Set(["__proto__", "constructor", "prototype"]);
function isPlainObject(v: unknown): v is Record<string, unknown> {
if (typeof v !== "object" || v === null) return false;
const proto = Object.getPrototypeOf(v);
return proto === Object.prototype || proto === null;
}
export function merge(target: any, source: any): any {
for (const key of Object.keys(source)) {
if (DANGEROUS.has(key)) continue;
if (isPlainObject(source[key])) {
target[key] = merge(target[key] ?? Object.create(null), source[key]);
} else {
target[key] = source[key];
}
}
return target;
}The `isPlainObject` check matters too. Without it, the recursion happily merges into arrays, dates, regexes, and (in earlier Node versions) into the global `Buffer` constructor — each of which has its own exploitation history.
Why human review missed it
Prototype pollution lives in a blind spot for engineers who learned JS after ES6 classes became common — the prototype chain is invisible in day-to-day code. The bug also requires understanding both the parser (does it preserve `__proto__`?) and the runtime (does direct property write mutate the chain?) to spot from a line of recursion code. Mesrai's rule pack catches every recursive-merge pattern without an explicit deny-list.
Related rules + further reading
Mesrai rule pack: security/no-prototype-pollution — flags recursive merge/extend/assign on untrusted input without `__proto__` block.
Snyk research: Prototype Pollution: The Dangerous and Underrated Vulnerability.
CWE-1321 — Improperly Controlled Modification of Object Prototype Attributes.
Takeaway
If you wrote your own merge, add the dangerous-keys deny-list today. If you use `lodash.merge`, stay on 4.17.20 or later. Mesrai catches the pattern in both cases — including the case where someone copies a Stack Overflow snippet into a new utility module a year from now.