Prototype pollution, DOM clobbering, postMessage without origin - JS-only attacks AI rarely guards against
Prototype pollution and DOM-based attacks show up when AI-generated frontends merge untrusted objects or sink user input into HTML. Patterns, payloads, and how to test for them.
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.
Why this article exists next to SQL injection guides
Most vibe-coding security content focuses on Supabase RLS and leaked keys - correctly, those dominate severity. Frontend-only classes still matter because:
- They enable account takeover via XSS gadgets after pollution or clobbering.
- They bypass server-side controls when postMessage triggers credentialed fetches.
- They show up in admin widgets, chat embeds, and design-system portals AI scaffolds eagerly.
If your threat model is pure static marketing site, deprioritize. If users log in and the SPA holds a session cookie or token, these bugs are in scope for any serious review.
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 read 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 that sounds fake until you 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.
Static vs dynamic for this class
| Approach | Catches | Misses |
|---|---|---|
Semgrep / CodeQL rules for __proto__ |
Obvious merges | Obfuscated keys, library gadgets |
| Dependency CVE feed | Known lib bugs | Hand-rolled merges AI just wrote |
| Manual code review | Context-rich | Easy to skip “boring” utils |
| Runtime pollution probe | Confirm impact | Needs gadget path |
| Headless postMessage fuzzer | Real handler bugs | Timing / rare branches |
Ship at least one runtime check in CI for apps that merge user JSON or embed third-party iframes.
Where AI generators reintroduce these bugs
These three classes show up in AI-written frontends for structural reasons:
| Pattern AI copies | Bug class | Why the model misses it |
|---|---|---|
| “Utility deepMerge for settings” | Prototype pollution | Looks like clean functional code |
| “Render bio as HTML for rich text” | DOM clobbering / XSS | Training data is full of innerHTML demos |
| “Listen for widget messages” | postMessage | Origin check is one line often omitted in snippets |
| “Config from window.__ENV” | Clobbering + pollution | Globals feel convenient in SPAs |
| “Parse query string into options” | Pollution via parsers | Old qs/query libraries were famous for this |
Cursor/Windsurf/Copilot will happily complete a merge helper mid-function. Lovable/Bolt rarely deep-merge server-side, but they do ship rich-text comments and iframe embeds that hit clobbering and postMessage.
Query-string and body parsers
Even without a hand-rolled deepMerge, pollution arrives through libraries:
// Dangerous if the parser merges into Object.prototype
const options = parse(location.search) // user-controlled
if (options.isAdmin) showAdmin()
Keep parsers updated. Prefer APIs that return null-prototype objects. Never use parsed query objects as authorization truth - server session is the source of identity.
Prototype pollution → gadget chains
Pollution alone is only half the exploit. The gadget is the code path that reads the polluted property:
- Auth:
if (user.isAdmin)whereuseris a plain object - Templating: options objects that enable
escape: false - Merge into config:
config.transport.urlbecomes attacker-controlled (SSRF-ish client fetches) - Feature flags:
flags.beta = trueon every object
When testing, inject:
{"__proto__": {"isAdmin": true, "role": "admin"}}
Then exercise admin UI, privileged API wrappers, and any code that copies properties with for...in or Object.assign onto fresh objects that still inherit Object.prototype.
Server-side Node is often more severe than browsers: one polluted process serves every tenant until restart. Client-side pollution may be tab-scoped but still steals sessions via XSS gadgets.
postMessage: full secure handler
const ALLOWED = new Set([
"https://app.example.com",
"https://admin.example.com",
])
window.addEventListener("message", (event) => {
if (!ALLOWED.has(event.origin)) return
// Optional: expect a structured shape
if (typeof event.data !== "object" || event.data === null) return
const { type, payload } = event.data as { type?: string; payload?: unknown }
switch (type) {
case "resize":
// validate payload fields; never eval
break
case "auth":
// NEVER accept tokens from foreign origins even if listed - prefer first-party auth
break
default:
return
}
})
Also set targetOrigin explicitly when sending - never *' for sensitive payloads:
otherWindow.postMessage({ type: "ready" }, "https://widget.example.com")
AI-generated chat widgets and payment iframes skip both sides of this contract constantly.
DOM clobbering beyond window.config
Other clobbering shapes:
document.getElementById('x')vs forms namedxshadowing collectionsdocument.querySelector('#x')still returns the attacker node if HTML injected- Base tag injection (
<base href="https://evil">) changing relative URL resolution - related HTML injection family - Clobbering
form.actionor submit buttons to rewrite where credentials post
Defense-in-depth: CSP that disables inline script, Trusted Types where supported, sanitizer that strips id/name from untrusted HTML, and application code that never uses DOM-backed globals for security decisions.
Testing recipes (manual)
Pollution
- Find JSON-accepting endpoints or client merge of
location.hash/ query. - Send
__proto__/constructor.prototypepayloads. - In DevTools console on the page (client) or after request (server), check
({}).isAdminor your gadget property. - Confirm admin routes or privileged branches flip.
Clobbering
- Find stored HTML fields (bio, markdown, CMS).
- Submit
<a id="expectedGlobal" href="https://evil.example">. - Load a page that reads
window.expectedGlobal. - Observe type is
HTMLAnchorElementand any fetch/navigation follows attacker data.
postMessage
- Host a page that iframes the target (or opens it).
iframe.contentWindow.postMessage({type:'updateProfile', payload:{...}}, '*')from attacker origin.- Watch network tab for credentialed requests fired by the handler.
Gapbench URLs in this article encode these recipes for automated demos.
Framework notes
- React: default text escaping helps XSS;
dangerouslySetInnerHTMLand markdown libraries re-open clobbering/XSS. Prefer sanitization before HTML sinks. - Vue:
v-htmlis the equivalent sink. Avoid for user content. - Svelte:
{@html}same story. - Next.js: server components do not remove client postMessage issues in third-party scripts.
- TypeScript: types do not stop
any-typed merges;safeMergemust enforce at runtime.
Fix summary
Prototype pollution: filter __proto__, constructor, prototype keys, or use Object.create(null), or use a vetted library. Treat query parsers and config loaders as merge boundaries.
DOM clobbering: sanitize HTML strictly (DOMPurify with id/name in the forbidden attributes), don’t read globals from window, prefer proper imports, consider CSP + Trusted Types.
postMessage: check event.origin against an allow-list. Always. Validate message shape. Set explicit targetOrigin when sending.
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/
Why AI code reintroduces pollution
Utility merge helpers and “flexible options objects” are common autocomplete results. Combined with recursive merges of user JSON, __proto__ pollution becomes reachable.
Hardening merges
function safeMerge(target, src) {
for (const [k, v] of Object.entries(src)) {
if (k === "__proto__" || k === "constructor" || k === "prototype") continue;
// assign carefully...
}
}
Prefer libraries with prototype-safe merges or structured clone + explicit field allowlists.
DOM sinks checklist
innerHTML, outerHTML, document.write, jQuery .html(), and markdown renderers without sanitization. AI UIs love rich text - sanitize on output with a maintained library and strict allowlists.
Clipboard paste XSS (adjacent class)
Editors that call innerHTML on paste events reintroduce XSS without a classic “stored bio” field:
// BAD
editable.addEventListener("paste", (e) => {
const html = e.clipboardData?.getData("text/html")
if (html) editable.innerHTML = html
})
Prefer text/plain, or sanitize HTML with DOMPurify before insertion. Live demo: clipboard-paste-xss.
Fragment / hash XSS
// BAD - classic training-data pattern
document.getElementById("out")!.innerHTML = location.hash.slice(1)
User-controlled URL fragment never hits the server, so SAST that only sees HTTP handlers misses it. Gapbench: dom-fragment-xss.
Node vs browser pollution impact
| Runtime | Blast radius | Typical gadget |
|---|---|---|
| Browser tab | Single user session | Flip client isAdmin UI; XSS gadget |
| Node process | All tenants until restart | Auth middleware, template options, merge into config |
Server-side pollution after a JSON body merge is often Critical multi-tenant impact. Client-side still enables account takeover when combined with token-in-localStorage and an HTML sink.
Hardening checklist for AI frontends
- Ban hand-rolled
deepMergeof user JSON; use allowlisted fields or patched lodash.merge. - DOMPurify (or equal) on every HTML sink; strip
id/namefrom untrusted HTML. - No
window.__CONFIGfrom DOM; use build-time imports. - Every
messagelistener checksevent.originallowlist. postMessagesenders set explicittargetOrigin, never*.- CSP + Trusted Types where the host allows.
- Semgrep rules for
__proto__+ runtime probe in CI for merge endpoints. - Review markdown/LLM HTML paths (LLM-rendered HTML).
Unit test for safeMerge
import { describe, it, expect } from "vitest"
import { safeMerge } from "./safeMerge"
describe("safeMerge", () => {
it("ignores __proto__", () => {
const t: Record<string, unknown> = {}
safeMerge(t, JSON.parse('{"__proto__":{"polluted":true}}'))
expect(({} as { polluted?: boolean }).polluted).toBeUndefined()
})
})
If this test is absent, treat every new merge helper as unreviewed.
JSON5, YAML, and querystring side doors
Pollution is not only JSON.parse of req.body:
- querystring parsers historically turned
?__proto__[isAdmin]=trueinto prototype writes. - YAML merges in config loaders with untrusted documents.
- JSON5 / loose parsers accepting
__proto__keys in non-strict modes.
Inventory every place user input becomes nested objects: feature-flag consoles, theme customizers, “advanced JSON” admin fields, and webhook fan-in that deep-merges payloads into stored config.
CSP as a mitigator, not a cure
Content-Security-Policy that blocks unsafe-inline and limits script-src reduces exploitation of XSS gadgets after clobbering or pollution. It does not stop prototype pollution on the server or postMessage handlers that run your own first-party script. Deploy CSP anyway (headers checker); keep origin checks and safeMerge.
Lodash and dependency CVEs vs hand-rolled merges
Teams sometimes assume “we don’t use lodash, so no pollution.” Hand-rolled three-line merges are more common in AI output than pulling lodash deliberately. Conversely, old lockfiles may still pull vulnerable set-value / merge transitive packages. Run both:
- SCA for known pollution CVEs (
npm audit, Snyk) - Semgrep/custom rules for recursive assign patterns
- Runtime probes on JSON merge endpoints
See JavaScript/React security scanners for pipeline placement.
Widget vendors and postMessage contracts
When you embed a third-party widget, document:
- Expected
event.originvalues - Allowed
typestrings - Whether messages may trigger authenticated fetches
- Who owns security review when the vendor changes origins
AI code that “listens for all messages and updates state” is a confused-deputy factory. Prefer vendor SDKs with documented origin checks over custom listeners.
Related reading
- Pattern: LLM-rendered HTML and Markdown
- Pattern: CORS = * with credentials = true
- Tool: vibe-code-scanner
- Pattern: Indirect prompt injection - related trust-boundary failures when content steers agents
Common questions
What is prototype pollution?
What is DOM clobbering?
What is the postMessage-without-origin issue?
Why do AI generators produce these?
Where can I see this on a real URL?
What CWE does this map to?
Test client API together
DOM sinks and polluted objects often pair with open APIs. Scan the full deployed surface, not just the frontend snippet.
14-day free trial · No credit card · Cancel anytime