DEV Community

Cover image for Why Cursor Keeps Writing Prototype Pollution Into Your JS
Charles Kern
Charles Kern

Posted on

Why Cursor Keeps Writing Prototype Pollution Into Your JS

TL;DR

  • AI editors reproduce a dangerous recursive merge pattern from pre-2019 training data
  • The pattern lets attackers inject properties onto Object.prototype, poisoning every object in the process
  • Fix: use structuredClone() or key blocklist guards -- never accept recursive merges on untrusted input

I was reviewing a side project a colleague built with Cursor. Clean structure, sensible abstractions. Then I checked the config merge utility he'd dropped in.

function merge(target, source) {
  for (const key of Object.keys(source)) {
    if (typeof source[key] === 'object') {
      merge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}
Enter fullscreen mode Exit fullscreen mode

Cursor wrote this. It's also the pattern that opened CVE-2019-10744 in lodash -- the same library used by millions of projects before the patch. The model doesn't know the pattern became dangerous. It just reproduces what worked in training data.

What the Exploit Looks Like

If that merge function touches any user-controlled input, an attacker sends this:

{ "__proto__": { "admin": true } }
Enter fullscreen mode Exit fullscreen mode

After that call, every plain object in the process inherits admin: true. Your auth checks, your role comparisons, your conditional logic -- all of them now read from a poisoned prototype.

const user = {};
console.log(user.admin); // true -- you never set this
Enter fullscreen mode Exit fullscreen mode

The object you created is clean. The prototype it reads from is not.

Why AI Keeps Generating This

The recursive merge pattern is plastered across training data. StackOverflow answers from 2014-2018, pre-patch lodash clones, hundreds of "deep merge in JavaScript" tutorials. The pattern worked perfectly before the security community understood prototype pollution. Now it lives in model weights, and there's no mechanism to mark it as deprecated-and-dangerous.

Prompt Cursor or Claude Code for "write a deep merge utility" and they reproduce this faithfully. It passes every unit test you write against normal inputs.

The Fix

Three safe options depending on your Node version:

// Option 1: structuredClone (Node 17+, strips prototype chain entirely)
const merged = structuredClone({ ...defaults, ...userInput });

// Option 2: JSON roundtrip (simple, strips prototype)
const merged = JSON.parse(JSON.stringify({ ...defaults, ...userInput }));

// Option 3: If you genuinely need recursive merge on internal config,
// add an explicit key blocklist (the lodash patch approach)
function safeMerge(target, source) {
  for (const key of Object.keys(source)) {
    if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
    if (typeof source[key] === 'object' && source[key] !== null) {
      target[key] = target[key] || {};
      safeMerge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}
Enter fullscreen mode Exit fullscreen mode

If your Node version is 17+, structuredClone is the clean answer. No prototype chain, no blocklist to maintain.

Quick Audit Command

grep -rn "target\[key\]" src/ --include="*.js" --include="*.ts"
grep -rn "merge(" src/ --include="*.js" --include="*.ts"
Enter fullscreen mode Exit fullscreen mode

If either hits on a recursive function that accepts external data, you have exposure. Worth running before your next deploy.

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 will catch most of what's in this post. The important thing is catching it early, whatever tool you use.

Top comments (0)