TL;DR
AI code generators reach for _.merge the second you ask them to "merge user settings with defaults." POST {"__proto__": {"isAdmin": true}} to that endpoint and every object in the process inherits isAdmin: true — including the session user the next request checks against.
The drill in 90 seconds
No containers, no scaffold, no repo to clone. One npm install, one file, one curl. You'll save a 25-line Express server, POST a single JSON body at it, and watch a property that doesn't exist on any object return true for every object in the process.
Step 1 — Save this file
Install two packages once: npm install express lodash. Then drop this into app.js.
// app.js
// This is the pattern AI generators reach for when you ask
// for "merge user settings with defaults". Lodash does the work,
// req.body walks straight into it, nothing validates the keys.
const express = require('express');
const _ = require('lodash');
const app = express();
app.use(express.json());
// Fake auth: the caller is logged in as user 47.
// Crucially, req.user has NO isAdmin property of its own.
function authenticate(req, res, next) {
req.user = { id: 47 };
next();
}
const settings = {};
app.post('/api/settings', authenticate, (req, res) => {
_.merge(settings, req.body);
res.json({ ok: true });
});
app.get('/api/me', authenticate, (req, res) => {
res.json({ id: req.user.id, isAdmin: req.user?.isAdmin ?? false });
});
app.listen(3000, () => console.log('listening on http://localhost:3000'));
Nothing exotic. Lodash ships in 40 million repos, and _.merge is the one-line utility every AI generator writes when the prompt mentions settings, config, or defaults.
Step 2 — Run it
node app.js
You should see listening on http://localhost:3000.
Step 3 — POST __proto__
First, the sanity check. Ask who you are.
curl http://localhost:3000/api/me
Now send a single settings update. Watch the key.
curl -X POST http://localhost:3000/api/settings \
-H 'Content-Type: application/json' \
-d '{"__proto__": {"isAdmin": true}}'
Re-run the same /api/me request. Nothing about req.user changed. No new code ran. The handler is byte-for-byte identical.
That's the punchline. You are now an admin on an endpoint that never touched the admin flag. Every object in the process — the session user, the request, the response, every model you load after this point — inherits isAdmin: true.
What just happened
A handful of bullets, then we move on.
_.mergewalks every key inreq.body, including the string"__proto__".- Writing to
__proto__on an object writes toObject.prototype. req.useris{ id: 47 }— noisAdminof its own. When the handler readsreq.user.isAdmin, JS walks the prototype chain and finds thetrueyou just planted.- This is prototype pollution. It's been known since at least 2018 and was the core of
CVE-2019-10744(lodash < 4.17.12, CVSS 9.1) andCVE-2020-8203(lodash < 4.17.20, CVSS 7.4).
The gadget chain (the part nobody talks about)
Pollution on its own is usually low-impact — a flipped flag, a hidden debug path. The real damage shows up when a "gadget" downstream reads from the polluted prototype.
The canonical one: pollute Object.prototype.outputFunctionName with a payload like x; return global.process.mainModule.require('child_process').execSync('...'), then trigger any ejs template render. ejs concatenates outputFunctionName into the compiled template source — your payload becomes executable code inside the renderer. That's CVE-2022-29078, ejs < 3.1.7, CVSS 9.8. Full remote code execution from a single POST. Pug and Handlebars have their own gadgets in the same family.
Fix it (30 seconds)
Two options, in order of preference.
// Option A (recommended): validate the shape before anything touches it.
// Zod's .strict() rejects unknown keys outright — __proto__ never reaches _.merge.
const { z } = require('zod');
const SettingsSchema = z.object({
theme: z.enum(['light', 'dark']).optional(),
notifications: z.boolean().optional(),
}).strict();
app.post('/api/settings', authenticate, (req, res) => {
const parsed = SettingsSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid input' });
_.merge(settings, parsed.data);
res.json({ ok: true });
});
// Option B (defense-in-depth): strip dangerous keys at the boundary.
// Use when a schema isn't practical — e.g. dynamic config shapes.
const DANGEROUS = new Set(['__proto__', 'constructor', 'prototype']);
function sanitize(obj) {
if (obj === null || typeof obj !== 'object') return obj;
for (const key of Object.keys(obj)) {
if (DANGEROUS.has(key)) delete obj[key];
else obj[key] = sanitize(obj[key]);
}
return obj;
}
Lodash 4.17.21 and later ship internal guards, but they're incomplete for nested paths and string-path assignments — schema validation is the reliable fix, and the one you'd keep even if the lodash patch were airtight.
Audit your own app
Three checks you can run against your real code right now.
- Grep for the usual sinks.
_.merge(,_.defaultsDeep(,_.set(,_.setWith(. For each match, trace the second argument. If it comes fromreq.body,req.query,req.params, or any parsed JSON from a request, you have the bug. Hand-rolleddeepMergeandextendutilities are just as bad — look forfor (const key in source)loops that assign without checking the key. - Check your dependency tree. Open
package.jsonand your lockfile. Anylodashbelow4.17.21is a finding on its own —npm auditflagsCVE-2020-8203as HIGH. The same logic applies tojquery < 3.4.0,minimist < 1.2.6, andmixin-deep. See npm supply-chain hygiene for vibe coders for the full list and how to keep it clean. - Look downstream for gadgets. If you render templates (
ejs,pug,handlebars), sign JWTs, or load config from merged objects, you have a live gadget. The distance between "pollution" and "RCE" is shorter than most teams realize.
Why AI code generators produce this
Deep-merge is one of the first utilities a generator writes from scratch when it doesn't reach for lodash — a for (const key in source) loop that assigns every key without checking it. The training data predates the CVEs, the quickstart tutorials that feed these models don't mention prototype guards, and the resulting pattern looks clean enough to pass review. No malice, no negligence. Same shape of miss as SQL injection in AI-generated code — a default that was safe once and isn't anymore.
Closing
A drill like this catches one polluted sink on one endpoint. The interesting chains are the compound ones — a settings endpoint pollutes outputFunctionName, a dashboard route renders an ejs template three files away, and the POST you treated as harmless turns into shell. That's what we built Flowpatrol for — the scanner walks the full gadget chain so you see not just the polluted input, but whether it reaches a template engine, a JWT library, or a logging function that turns the flipped flag into something louder. Paste your URL, see what comes back.
Want the sibling drill in the same shape? IDOR in 60 seconds: change a 1 to a 2 and see what comes back walks a missing-check bug the same way — one file, one curl, one fix.