Detect Prototype Pollution in JavaScript Code Review Checklist
Prototype pollution is one of those vulnerabilities that looks like a boring object-merge bug until it grants every user in your app isAdmin: true. An attacker submits JSON containing a __proto__ key, your utility function walks the properties without checking them, and suddenly Object.prototype has a new field that all objects in the process inherit. The bug appears in merge utilities, config loaders, query-string parsers, and anywhere your code recursively copies untrusted data onto an object. This checklist tells you exactly where to look.
How prototype pollution actually works
Every plain object in JavaScript inherits from Object.prototype via its internal [[Prototype]] slot. When you access a property that doesn't exist on an object directly, the engine walks the prototype chain. If you can write to Object.prototype, every object in the process picks up that property as if it were its own.
The three magic keys: __proto__, constructor, and prototype. All three let attacker input escape the target object and land on shared prototypes. The canonical trigger is a naive recursive merge:
// Vulnerable deep merge — do not ship this
function merge(target, source) {
for (const key of Object.keys(source)) {
// No key validation — attacker controls key
if (typeof source[key] === "object" && source[key] !== null) {
if (!target[key]) target[key] = {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
const payload = JSON.parse('{"__proto__": {"isAdmin": true}}');
merge({}, payload);
// Now every plain object inherits isAdmin
const user = { name: "alice" };
console.log(user.isAdmin); // true — prototype chain was silently mutated
When merge recurses into payload.__proto__, target[key] resolves to Object.prototype itself (because {}.__proto__ === Object.prototype). The function then writes isAdmin: true directly onto Object.prototype, and every subsequently created {} inherits it.
The impact isn't theoretical. CVE-2019-10744 in lodash before 4.17.12 is exactly this pattern through _.defaultsDeep. CVE-2020-8203 is the same in lodash _.merge. Real exploit chains have produced RCE via template engines (Handlebars, Pug) that call Function() constructor after reading polluted properties, and auth bypasses when middleware checks req.user.isAdmin against a prototype-inherited value.
For a deeper walkthrough of the mechanics and a hands-on lab environment, the prototype pollution lesson on Code Review Lab covers the full exploit chain including the Handlebars RCE path.
The baseline fix: block dangerous keys and freeze prototypes
Two independent defenses: reject bad keys at merge time, and make the pollution surface as small as possible by freezing Object.prototype at boot.
// Safe deep merge with key allowlisting
const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
function safeMerge(target, source) {
if (typeof source !== "object" || source === null) return target;
for (const key of Object.keys(source)) {
// Reject before recursing — gadget chains can fire from constructors
if (DANGEROUS_KEYS.has(key)) continue;
const srcVal = source[key];
if (typeof srcVal === "object" && srcVal !== null && !Array.isArray(srcVal)) {
// Use Object.create(null) for intermediate nodes — no prototype chain at all
if (!Object.prototype.hasOwnProperty.call(target, key)) {
target[key] = Object.create(null);
}
safeMerge(target[key], srcVal);
} else {
target[key] = srcVal;
}
}
return target;
}
// Boot-time hardening — freeze the shared prototype so writes throw in strict mode
// or silently fail in sloppy mode, rather than silently succeeding
Object.freeze(Object.prototype);
// For lookup tables that should never inherit anything, use null-prototype objects
const lookup = Object.create(null);
lookup["someKey"] = "someValue";
// lookup.__proto__ is undefined — prototype chain attack is impossible here
Object.freeze(Object.prototype) at application startup is a low-cost defense-in-depth measure. It won't stop all gadget chains (some libraries create intermediate objects before the property is read), but it converts silent corruption into a thrown error or a no-op, both of which are much easier to detect.
The tradeoff with Object.create(null) objects: they don't have .toString(), .hasOwnProperty(), or any other prototype methods. Code that calls obj.hasOwnProperty(k) directly on a null-prototype object will throw. Use Object.prototype.hasOwnProperty.call(obj, k) instead, which is the safe pattern regardless.
For string-keyed dynamic data from external sources, prefer Map over plain objects. Map has no prototype chain for key lookups: a Map with key "__proto__" just stores a string, it doesn't touch any prototype.
Sink patterns to grep for during review
The following patterns are where a prototype-polluted value does damage. Finding a sink doesn't mean the code is vulnerable, but every hit deserves a "where does the source come from?" question.
# Run these from the repo root with ripgrep
# Each flag captures common real-world spellings
# Recursive or object-spreading merges
rg "merge\s*\(" --type js
# Lodash utilities known to be historically vulnerable
rg "\.defaultsDeep\s*\(|lodash\.merge\s*\(|_\.merge\s*\(|_\.set\s*\(" --type js
# Object.assign with a non-literal second argument (source could be attacker-controlled)
rg "Object\.assign\s*\([^,]+,\s*req\." --type js
# Dynamic two-level key assignment: obj[key1][key2] = value
# This pattern can walk __proto__ if key1 is "__proto__"
rg "\[[a-zA-Z_$][a-zA-Z0-9_$]*\]\s*\[[a-zA-Z_$][a-zA-Z0-9_$]*\]\s*=" --type js
# JSON.parse result fed directly into a property setter or merge
rg "JSON\.parse\s*\(.*\)\s*[,;]" --type js -A 2
# Template engines reading from config objects (common RCE gadget sink)
rg "options\[|config\[|settings\[" --type js
_.set(obj, path, value) is particularly dangerous because it accepts dot-notation paths like "__proto__.isAdmin". If path comes from user input, it's a direct pollution vector even when the top-level merge is safe.
The two-level dynamic bracket assignment (obj[a][b] = v) pattern is the one reviewers most often miss. It doesn't look like a prototype operation on the surface. When a is "__proto__" and b is "isAdmin", it is one.
When you find a sink, also check whether the surrounding code uses polluted properties for access control. The auth bypass via polluted defaults pattern appears frequently in middleware that reads req.user.role or user.isAdmin from a plain object without confirming the property is own.
Source patterns: where attacker keys enter the app
The source side gets less attention than sinks in most checklists. Here are the entry points reviewers frequently miss.
req.body with express.json() is the obvious one. Any JSON object in the request body can contain __proto__.
req.query with qs is less obvious. Express uses the qs library by default for query-string parsing, and qs supports bracket notation for nested objects. An attacker can send:
// Express route — attacker sends:
// GET /search?a[__proto__][polluted]=1
// qs parses this into: { a: { __proto__: { polluted: '1' } } }
const express = require("express");
const app = express();
app.get("/search", (req, res) => {
// req.query is already parsed by qs — the __proto__ key is live in the object
// Passing this directly to a merge function pollutes Object.prototype
const options = {};
Object.assign(options, req.query); // unsafe if req.query contains __proto__
res.json({ status: "ok" });
});
Note: qs 6.10+ has a allowPrototypes option (default false) that strips __proto__ during parsing. If your project pins an older version or sets allowPrototypes: true, you're exposed.
WebSocket message handlers deserve the same scrutiny as HTTP handlers. ws.on('message', data => ...) is a source that reviewers often skip because it doesn't look like an HTTP endpoint.
MongoDB query filters constructed from req.body can include __proto__ as a field name. MongoDB itself won't interpret it as a prototype key, but code that later merges the filter result into a config object will.
YAML parsing via js-yaml in DEFAULT_FULL_SCHEMA mode (the pre-4.x default) allows arbitrary JavaScript object construction, which includes prototype manipulation. If you're parsing user-supplied YAML and haven't pinned to safe load mode, that's a source.
The reviewer's checklist (copy-paste)
Paste this block into your PR review template or a review comment.
Sources: untrusted input
- [ ] Does any
req.body,req.query,req.params, or WebSocket message flow into a merge, assign, or_.set? - [ ] Is
qsversion >= 6.10, and isallowPrototypesexplicitly false? - [ ] Are YAML or CSV imports using safe-load modes with no schema that permits arbitrary types?
- [ ] Are MongoDB or Redis results merged into shared config objects?
Sinks: dangerous operations on attacker-influenced objects
- [ ] Does any recursive merge validate keys against
__proto__,constructor,prototype? - [ ] Are
_.merge,_.defaultsDeep, or_.setreceiving untrusted input? Check the lodash version (must be >= 4.17.21 for CVE-2021-23337 and related). - [ ] Are there any
obj[userKey1][userKey2] = valuepatterns where either key comes from outside? - [ ] Does
Object.assignreceive a spread or a direct reference to parsed user data as its source?
Safe-object usage
- [ ] Are lookup tables that hold user-supplied keys using
MaporObject.create(null)instead of{}? - [ ] Does the app call
Object.freeze(Object.prototype)at startup, or is there an equivalent library (--disable-proto=deleteV8 flag)?
Dependency audit
- [ ] Does
npm auditor Snyk report any prototype-pollution CVEs in the dependency tree? - [ ] Is lodash >= 4.17.21? (Most merge-related CVEs cluster here.)
Regression test
Every PR that touches a merge path should ship a test like this:
// Jest regression test for prototype pollution
const { safeMerge } = require("./utils/merge");
describe("safeMerge", () => {
beforeEach(() => {
// Confirm baseline — Object.prototype should be clean before each test
expect(({}).polluted).toBeUndefined();
});
test("rejects __proto__ key and does not pollute Object.prototype", () => {
const payload = JSON.parse('{"__proto__": {"polluted": true}}');
safeMerge({}, payload);
// If pollution occurred, every new object would inherit `polluted`
expect(({}).polluted).toBeUndefined();
});
test("rejects constructor.prototype key path", () => {
const payload = JSON.parse('{"constructor": {"prototype": {"polluted": true}}}');
safeMerge({}, payload);
expect(({}).polluted).toBeUndefined();
});
afterEach(() => {
// Belt-and-suspenders cleanup in case a test fails mid-run
delete (Object.prototype as any).polluted;
});
});
The afterEach cleanup matters: a failing test mid-suite that successfully pollutes Object.prototype will corrupt every subsequent test in the same process unless you clean up.
Tooling: linters, scanners, and CI gates
ESLint: eslint-plugin-security flags object[variable] access patterns (rule security/detect-object-injection). It's noisy but catches the dynamic key assignment pattern. Add it to your ESLint config and suppress false positives with inline comments rather than blanket disables.
Semgrep: The Semgrep registry has community rules for prototype pollution. You can also write targeted rules for your codebase. Here's one that flags two-level dynamic assignment where the outer key comes from a request object:
# semgrep-rules/prototype-pollution.yaml
rules:
- id: dynamic-two-level-assignment-from-request
languages: [javascript, typescript]
message: >
Dynamic two-level bracket assignment with a request-derived key.
If the outer key is __proto__, this pollutes Object.prototype.
Validate both keys against an allowlist before assignment.
severity: WARNING
patterns:
- pattern: $OBJ[$KEY1][$KEY2] = $VAL
- pattern-either:
- pattern-inside: |
$KEY1 = req.$X
...
- pattern-inside: |
const $KEY1 = req.$X
...
- pattern-inside: |
let $KEY1 = req.$X
...
metadata:
cwe: "CWE-1321"
references:
- https://cwe.mitre.org/data/definitions/1321.html
npm audit and Snyk: Known CVEs in lodash, hoek, merge, and similar libraries show up here. This is the fastest win: npm audit --audit-level=moderate in CI, failing on anything moderate or above, catches the published CVEs immediately. Snyk adds transitive dependency analysis that npm audit sometimes misses.
V8 flags: For Node.js services where you control the startup command, --disable-proto=delete removes the __proto__ accessor entirely from Object.prototype. This is a hard engine-level mitigation. The tradeoff is that any library relying on __proto__ for legitimate use breaks, which is rare but non-zero. --disable-proto=throw is the stricter variant that throws on access instead of silently deleting the accessor.
You can explore the detection tooling and CI integration patterns further at Code Review Lab, which has a guided exercise specifically on finding pollution vectors in pull request diffs.
Related risks to check in the same PR
When you find a prototype pollution candidate in a PR, the same untrusted-key-handling weakness usually signals other vulnerability classes nearby. Widen the review scope before approving.
HTTP parameter pollution: When a query parameter appears twice (?role=user&role=admin), parsers handle it differently. Express/qs returns an array; some custom parsers take the last value, others the first. Code written assuming a scalar that gets an array can bypass input validation. The HTTP parameter pollution pattern often co-occurs with prototype pollution because both rely on a parser feeding unexpected key shapes into application logic.
DOM clobbering and XSS: On the client side, if polluted prototype properties reach template rendering (Handlebars, Pug, Nunjucks), you get RCE server-side or XSS client-side. Handlebars versions before 4.7.7 had a known gadget chain. Pug before 3.0.1 had another. The DOM clobbering and advanced XSS patterns share the same root cause: attacker-controlled property names escaping the expected object boundary.
Auth bypass: If your middleware checks if (user.isAdmin) and user is a plain object, a polluted Object.prototype.isAdmin = true satisfies that check for any user object in the process, including {}, which is the default value many auth middlewares fall back to on parse failure. This is the impact chain worth documenting in your security review notes.
Any PR that touches query-string parsing, config merging, or role/permission checks deserves all three of these as parallel review threads, not just a prototype pollution scan in isolation.
If you take one thing from this checklist: add the two-line Object.freeze(Object.prototype) call to your application entry point right now, then schedule a targeted rg "\[.*\]\[.*\]\s*=" pass over the codebase. Freeze converts silent corruption into a detectable error, and the grep surfaces the patterns worth reading carefully. Both take less than ten minutes.
Top comments (0)