PROTOTYPE POLLUTION, DOM CLOBBERING, POSTMESSAGE

JavaScript has a small family of attacks that don't exist in other languages. Prototype pollution. DOM clobbering. postMessage without origin checks. AI generators reproduce all three because the unsafe pattern is shorter than the safe pattern.

The scenario referenced below runs on gapbench.vibe-eval.com — a public security benchmark we operate.

Three quirks of JavaScript

The three bugs in this article all live in JavaScript-specific corners. They don’t have obvious analogs in Python, Go, or Rust. They look fine to a code reviewer who isn’t specifically watching for them. They look fine to a static scanner unless the rules were built for these specific patterns. AI generators reproduce them because the unsafe code looks like idiomatic JavaScript.

Prototype pollution

function deepMerge(target: any, source: any) {
  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
}

deepMerge(config, JSON.parse(req.body))

That deepMerge is everywhere. It’s three lines, it works. It also lets JSON.parse('{"__proto__": {"isAdmin": true}}') write to Object.prototype, because for...in iterates inherited properties and the merge copies them onto the target’s prototype.

The result: every object in the process now sees isAdmin: true unless something downstream explicitly checks. If your auth middleware reads req.user.isAdmin, the attacker is now an admin.

Libraries with this bug get CVEs regularly — lodash, jquery, set-value, mixin-deep have all had prototype pollution issues. The libraries are patched, but the patterns the AI writes by hand reintroduce the bug regularly.

The fix is to filter __proto__, constructor, and prototype as keys, or to use Object.create(null) as the target so there’s no prototype to pollute, or to use a library that handles this (modern Lodash has, modern fast-querystring has).

Live: https://gapbench.vibe-eval.com/site/prototype-pollution/.

DOM clobbering

This one is genuinely surprising the first time you see it.

<a id="config" href="https://attacker.example/payload.json"></a>

If that HTML is on your page — because a user’s profile bio rendered without HTML stripping, or because Markdown allowed raw HTML, or because a comment field rendered through innerHTML — then JavaScript code that does window.config gets back the <a> element instead of whatever it expected. Code like:

const url = window.config?.url ?? '/api/default'
fetch(url).then(...)

Now reads url from the href attribute of the attacker’s anchor, fetches the attacker’s URL, processes the attacker’s response. The fetch wasn’t injected — the URL was clobbered.

The fix is layered. Sanitize HTML before rendering (DOMPurify, with a strict config that strips id attributes from user content). Don’t reach for globals via window.X; use proper module-scoped variables. If you must use globals, use Object.hasOwn(window, 'X') checks that distinguish DOM elements from your variables.

Live: https://gapbench.vibe-eval.com/site/dom-clobbering/. Adjacent variant: https://gapbench.vibe-eval.com/site/dom-fragment-xss/innerHTML = location.hash, which is the canonical “user controls content via URL fragment” XSS.

postMessage without origin

window.addEventListener('message', (event) => {
  // No origin check
  const { type, payload } = event.data
  if (type === 'updateProfile') {
    fetch('/api/me', { method: 'PATCH', body: JSON.stringify(payload), credentials: 'include' })
  }
})

That handler accepts messages from any window. An attacker hosts a page that opens your site as an iframe (or popup). The attacker’s page sends a message with type: 'updateProfile' and a payload of the attacker’s choice. Your handler runs the fetch with the user’s credentials. The user’s profile is now whatever the attacker wanted.

The fix is one line: if (event.origin !== 'https://your-trusted-origin.com') return. AI-generated postMessage handlers omit this line in roughly half the cases we see. The line is also the entire defense — without it, the handler is trivially exploitable.

Live: https://gapbench.vibe-eval.com/site/postmessage-no-origin/.

A specific incident

Anonymized. The product was a feature-flag service (small one — not LaunchDarkly, more like a homegrown internal-tools build). The API let teams configure flag rules with JSON payloads. Team admins POSTed flag rules; the server merged them into the live config.

The merge function was the same deepMerge we showed above. A team admin discovered, by accident, that posting {"__proto__": {"isAdmin": true}} made every subsequent user appear to be an admin in the dashboard for the duration of the process. They reported it. The team treated it as a critical incident: the prototype pollution had been live for ~6 months, and the dashboard had occasionally shown anomalous “admin” markers on users who shouldn’t have been admin. They couldn’t trace whether this had been exploited or had only been the accidental friendly-fire from sloppy testing.

The fix was a one-line filter at the merge boundary plus a switch to lodash.merge (which is patched). Plus an audit of every other place in the codebase where deepMerge-style code lived. They found two other instances. Cleaned them up.

The lesson: prototype pollution is one of those bugs where the impact depends on what else in the runtime reads from the polluted property. If nothing reads obj.isAdmin, the pollution is invisible. If something does, the impact is total.

DOM clobbering — the surprising one

DOM clobbering is the bug most people don’t believe is real until they see it. The mental model:

  • Some HTML elements with id or name attributes get exposed as global properties on window.
  • This is legacy browser behavior, preserved for backward compatibility, and it’s not going away.
  • If user-supplied HTML reaches the page (via a sanitizer that allows id, via a Markdown renderer, via a “raw HTML allowed” CMS field), the user can shadow JavaScript globals.

The classic exploit shape:

<!-- attacker's profile bio renders as: -->
<a id="config" href="https://attacker.example/payload.json"></a>
// JS code that previously worked:
const url = window.config?.endpoint || '/api/default'
fetch(url).then(...)
// Now reads window.config = the <a> element
// .endpoint is undefined, but the optional-chain falls through
// What if the code was:
const url = window.config || '/api/default'
fetch(url).then(...)
// Now url = the <a> element, which when stringified gives... the href
// Fetches attacker URL with browser's credentials

There are dozens of variants. The general defense:

  1. Sanitize HTML strictly — DOMPurify with ALLOWED_ATTR excluding id and name.
  2. Avoid reading from window.X for application config; use proper imports.
  3. Where you must use globals, check Object.hasOwn(window, 'X') and verify the type.

Wrong fix vs right fix

// WRONG: deep merge with a key blocklist that misses constructor
function safeMerge(target: any, source: any) {
  for (const k in source) {
    if (k === '__proto__') continue  // not enough
    target[k] = source[k]
  }
}
// Misses: constructor.prototype mutation
// WRONG: deep merge with .hasOwnProperty check
function safeMerge(target: any, source: any) {
  for (const k of Object.keys(source)) {
    // Object.keys skips inherited props, but doesn't filter __proto__ as a key
    target[k] = source[k]
  }
}
// RIGHT: filter dangerous keys explicitly + use null-prototype targets
const FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype'])
function safeMerge(target: any, source: any) {
  for (const k of Object.keys(source)) {
    if (FORBIDDEN.has(k)) continue
    if (typeof source[k] === 'object' && source[k] !== null) {
      if (!target[k] || typeof target[k] !== 'object') target[k] = Object.create(null)
      safeMerge(target[k], source[k])
    } else {
      target[k] = source[k]
    }
  }
}
// RIGHT: use a vetted library
import merge from 'lodash.merge'
// Modern lodash.merge filters __proto__ and constructor

Cross-stack notes

  • JavaScript/TypeScript is where prototype pollution and DOM clobbering live. Other languages don’t have an equivalent prototype chain that user input can write to.
  • Python has class-attribute pollution as a tangentially-related bug — setattr on user-controlled keys can mutate class state. Less common in web codebases.
  • Ruby has class reopening, but it requires explicit class ClassName syntax that user input doesn’t trivially produce.
  • postMessage, by contrast, is universal — any framework that runs in a browser has the same trust issue.

How we detect

Prototype pollution: we identify endpoints that accept JSON bodies and probe with __proto__ payloads. We then re-fetch the affected user’s profile (or trigger a code path that reads from Object.prototype) and check whether the polluted property is observable. False positives are low; static scanners flag the pattern of deep-merge but can’t confirm exploitability without runtime testing.

DOM clobbering: we check whether user-supplied content can include HTML, and if so, whether id attributes survive sanitization. We then probe a synthetic clobber and observe via JavaScript whether window.config (or whatever the page expects) is now an HTML element.

postMessage: we open the page with our own parent window, post messages with various shapes, and observe whether the page acts on them. The detection requires a headless browser; runtime is the only way.

Fix summary

Prototype pollution: filter __proto__, constructor, prototype keys, or use Object.create(null), or use a vetted library.

DOM clobbering: sanitize HTML strictly (DOMPurify with id in the forbidden attributes), don’t read globals from window, prefer proper imports.

postMessage: check event.origin against an allow-list. Always.

CWE / OWASP

  • CWE-1321 — Improperly Controlled Modification of Object Prototype Attributes (Prototype Pollution)
  • CWE-79 — Improper Neutralization of Input During Web Page Generation (DOM clobbering, paste XSS, fragment XSS)
  • CWE-345 — Insufficient Verification of Data Authenticity (postMessage without origin)
  • OWASP Top 10 — A03:2021 Injection

Reproduce it yourself

COMMON QUESTIONS

01
What is prototype pollution?
JavaScript objects inherit from a prototype chain. If you can write to Object.prototype — for example by deep-merging user input into a shared object — every object in the runtime sees your write. An attacker setting __proto__.isAdmin = true can flip an isAdmin check elsewhere in the app to true. The vulnerability lives in deep-merge utilities, query-string parsers, and config loaders that don't filter __proto__/constructor/prototype as keys.
Q&A
02
What is DOM clobbering?
If your HTML contains an element with id='config', then in some legacy browser behaviors (still respected for backward compat), window.config refers to that element. Attacker-controlled HTML — for example, in a comment, a profile bio, or anything else that ends up rendered — can shadow JavaScript globals by id-name. If your code reads window.foo expecting an object, and the attacker can inject <a id='foo'>, your code now reads an HTMLAnchorElement with attacker-controlled attributes.
Q&A
03
What is the postMessage-without-origin issue?
window.postMessage is the standard way iframes and parent windows communicate. The receiving handler is supposed to check event.origin to ensure the message came from a trusted source. AI-generated postMessage handlers frequently skip this check, accepting messages from any origin. An attacker who can get a victim to load a page with a malicious iframe can send messages that the parent treats as trusted internal commands.
Q&A
04
Why do AI generators produce these?
All three are JavaScript-specific gotchas with no obvious red flag in source. Object.assign with user input is a normal-looking pattern. element.id = 'foo' is a normal-looking pattern. window.addEventListener('message', handler) without origin filtering is a normal-looking pattern. The bugs are in what's missing, and 'missing' doesn't pattern-match for the AI.
Q&A
05
Where can I see this on a real URL?
https://gapbench.vibe-eval.com/site/prototype-pollution/, https://gapbench.vibe-eval.com/site/dom-clobbering/, https://gapbench.vibe-eval.com/site/postmessage-no-origin/. Plus https://gapbench.vibe-eval.com/site/dom-fragment-xss/ for the related innerHTML-on-location-hash variant, and https://gapbench.vibe-eval.com/site/clipboard-paste-xss/ for the innerHTML-on-paste pattern.
Q&A
06
What CWE does this map to?
CWE-1321 (Prototype Pollution), CWE-79 (XSS, for the DOM-clobbering and postMessage variants), CWE-345 (Insufficient Verification of Data Authenticity for postMessage). OWASP A03:2021 (Injection).
Q&A

TEST YOUR FRONTEND

We probe the JS-specific attack surfaces — prototype pollution, DOM clobbering, postMessage handlers.

RUN THE SCAN