← Toutes les actualités
security3 min de lecture

A deep merge helper with no key check is a prototype pollution bug

Par Seaworthy · 3 septembre 2026

A close-up of a green light in a server

Photo by Tyler on Unsplash

A "merge the user's update into their profile" endpoint is one of the most common things an AI assistant gets asked to scaffold, and a deep merge helper is the natural tool for the job:

function deepMerge(target, source) {
  for (const key in source) {
    if (typeof source[key] === 'object' && source[key] !== null) {
      target[key] = deepMerge(target[key] || {}, source[key])
    } else {
      target[key] = source[key]
    }
  }
  return target
}

app.patch('/api/profile', (req, res) => {
  const updated = deepMerge(currentProfile, req.body)
  res.json(updated)
})

It handles nested objects correctly, which is exactly what it was written to do, and it passes every test you'd naturally write for a profile update. It also has no concept of which keys are safe to write.

__proto__ is a key like any other, until it isn't

JavaScript objects inherit from Object.prototype by default, and __proto__ is the property name that exposes that prototype link. A for...in loop walking an object's own keys treats __proto__ exactly like name or email, an ordinary string key with a value to copy. Send this as the request body:

{
  "__proto__": {
    "isAdmin": true
  }
}

and deepMerge walks into target.__proto__, which resolves to Object.prototype itself, and sets isAdmin: true on it. That's not a property on this one profile object anymore. It's now the default value of isAdmin for every plain object in the running process, including ones created long before this request arrived and ones that haven't been created yet. Anywhere else in the codebase that checks if (user.isAdmin) on an object that never explicitly set that field will now read true, because the check falls through to the polluted prototype.

The blast radius depends on what the rest of the app does with plain objects, which in a typical Node service is nearly everything, so the actual impact ranges from a confusing bug report to an authentication bypass, depending entirely on what a downstream if statement happens to check.

Reject the dangerous keys before the loop touches them

const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype'])

function deepMerge(target, source) {
  for (const key of Object.keys(source)) {
    if (DANGEROUS_KEYS.has(key)) continue
    if (typeof source[key] === 'object' && source[key] !== null) {
      target[key] = deepMerge(target[key] || {}, source[key])
    } else {
      target[key] = source[key]
    }
  }
  return target
}

Object.keys(source) rather than for...in is part of the fix too: Object.keys only returns the object's own enumerable properties, while for...in also walks inherited ones, which is a second, quieter way pollution-adjacent bugs creep into code that iterates objects without thinking about where the keys came from.

Don't hand-roll this if you don't have to

Writing your own merge function is exactly the kind of small, self-contained utility a coding assistant is happy to produce on request, and exactly the kind of thing that's easy to get subtly wrong once. lodash.merge before 4.17.21 had this same class of bug and was patched; Object.assign in a loop has the identical __proto__ problem for shallow cases. If a maintained library's merge function has already had this vulnerability found and fixed, use the patched version instead of writing a new implementation for a coding assistant to get wrong the same way again.

Seaworthy flags recursive object merge functions with no check against prototype-polluting keys.

Cet article a été généré par IA et résume des sources publiquement disponibles.

Par les créateurs de Seaworthy

Seaworthy analyse votre dépôt pour détecter les problèmes abordés dans des articles comme celui-ci.

Failles de sécurité, secrets exposés et mauvaises configurations détectés avant le déploiement. Gratuit, aucun compte requis.

$ npx seaworthycode