DEV Community

Cover image for Prototype Pollution: What Cursor's Object Merge Code Misses
Charles Kern
Charles Kern

Posted on

Prototype Pollution: What Cursor's Object Merge Code Misses

TL;DR

  • Cursor and Claude Code default to for...in object merge -- a CWE-1321 prototype pollution vector
  • Root cause: AI training data skews toward pre-2019 StackOverflow answers that predate Object.hasOwn()
  • One-line fix closes it entirely -- AI just never adds it unless you ask

Last week I was reviewing a side project a friend asked me to look over. Node backend, built almost entirely in Cursor. Clean structure, good variable names, even some inline comments. Genuinely readable. Then I hit the utility functions.

One was a deep merge helper. The kind every backend has -- takes two objects, recursively merges keys. AI writes these instantly. The problem is what it doesn't write.

Here's what Cursor generated, verbatim:

// CWE-1321 -- prototype pollution via for...in merge
function deepMerge(target, source) {
  for (const key in source) {
    if (source[key] && typeof source[key] === 'object') {
      target[key] = deepMerge(target[key] || {}, source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}
Enter fullscreen mode Exit fullscreen mode

for...in iterates inherited properties. If an attacker controls the source object -- via a JSON body, a query param, a config file -- they can inject __proto__ as a key and pollute Object.prototype for the entire Node process.

Concretely:

deepMerge({}, JSON.parse('{"__proto__": {"isAdmin": true}}'));
console.log({}.isAdmin); // true -- every object in the process is now an admin
Enter fullscreen mode Exit fullscreen mode

Admin endpoints become public. Audit logs get skipped. Auth middleware that checks req.user.isAdmin becomes worthless.

Why AI Keeps Writing This

The for...in merge is the canonical StackOverflow answer from roughly 2013 to 2019. Thousands of upvoted posts, hundreds of blog tutorials, dozens of npm packages that shipped this pattern and never updated. LLMs trained on that corpus reproduce it faithfully.

Object.hasOwn() and explicit __proto__ guards are more recent conventions. They exist in the training data, but mostly in security-focused articles -- not in the general "how to deep merge objects in JS" posts that dominate the corpus. The model defaults to the most statistically common answer. That answer is the insecure one.

This isn't specific to Cursor. Claude Code does it. Copilot does it. Any model trained on pre-2020 JavaScript tutorials will reproduce this pattern. The only thing that changes it is context -- if your prompt or surrounding code signals a security focus, the model sometimes adds the guard. Most prompts don't.

The Fix

Two options. Pick one.

Option 1 -- Guard with Object.hasOwn() (minimal change):

function deepMerge(target, source) {
  for (const key in source) {
    if (!Object.hasOwn(source, key)) continue; // blocks __proto__ + inherited keys
    if (source[key] && typeof source[key] === 'object') {
      target[key] = deepMerge(target[key] || {}, source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}
Enter fullscreen mode Exit fullscreen mode

Option 2 -- Switch to Object.keys() (only own enumerable props, no inherited iteration):

function deepMerge(target, source) {
  Object.keys(source).forEach(key => {
    if (source[key] && typeof source[key] === 'object') {
      target[key] = deepMerge(target[key] || {}, source[key]);
    } else {
      target[key] = source[key];
    }
  });
  return target;
}
Enter fullscreen mode Exit fullscreen mode

Option 1 is the minimal change if you're not refactoring. Option 2 is cleaner and removes the inherited iteration entirely.

If you're parsing untrusted JSON before merging, add a shape check:

function isSafeObject(obj) {
  return !('__proto__' in obj) && !('constructor' in obj) && !('prototype' in obj);
}
Enter fullscreen mode Exit fullscreen mode

Reject payloads that fail this check before they reach your merge function.

One More Thing

This pattern doesn't just live in utility files. It shows up in config loaders, settings mergers, patch endpoints, and anywhere an AI generates "merge user input with defaults." Search your repo for for (const key in and audit every hit. The vulnerability surface is usually wider than one file.

I've been running SafeWeave for this. It hooks into Cursor and Claude Code as an MCP server and flags these patterns before I move on. That said, even a basic pre-commit hook with semgrep and gitleaks will catch most of what's in this post. The important thing is catching it early, whatever tool you use.

Top comments (0)