Example finding How it works Coverage PENTEST METHODOLOGY DOCS PRICING FAQ MCP CONTACT LOG IN SIGN UP →

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 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.

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) where user is a plain object
  • Templating: options objects that enable escape: false
  • Merge into config: config.transport.url becomes attacker-controlled (SSRF-ish client fetches)
  • Feature flags: flags.beta = true on 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 named x shadowing collections
  • document.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.action or 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

  1. Find JSON-accepting endpoints or client merge of location.hash / query.
  2. Send __proto__ / constructor.prototype payloads.
  3. In DevTools console on the page (client) or after request (server), check ({}).isAdmin or your gadget property.
  4. Confirm admin routes or privileged branches flip.

Clobbering

  1. Find stored HTML fields (bio, markdown, CMS).
  2. Submit <a id="expectedGlobal" href="https://evil.example">.
  3. Load a page that reads window.expectedGlobal.
  4. Observe type is HTMLAnchorElement and any fetch/navigation follows attacker data.

postMessage

  1. Host a page that iframes the target (or opens it).
  2. iframe.contentWindow.postMessage({type:'updateProfile', payload:{...}}, '*') from attacker origin.
  3. 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; dangerouslySetInnerHTML and markdown libraries re-open clobbering/XSS. Prefer sanitization before HTML sinks.
  • Vue: v-html is 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; safeMerge must 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

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

  1. Ban hand-rolled deepMerge of user JSON; use allowlisted fields or patched lodash.merge.
  2. DOMPurify (or equal) on every HTML sink; strip id/name from untrusted HTML.
  3. No window.__CONFIG from DOM; use build-time imports.
  4. Every message listener checks event.origin allowlist.
  5. postMessage senders set explicit targetOrigin, never *.
  6. CSP + Trusted Types where the host allows.
  7. Semgrep rules for __proto__ + runtime probe in CI for merge endpoints.
  8. 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]=true into 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:

  1. SCA for known pollution CVEs (npm audit, Snyk)
  2. Semgrep/custom rules for recursive assign patterns
  3. 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.origin values
  • Allowed type strings
  • 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.

Common questions

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 is in deep-merge utilities, query-string parsers, and config loaders that don't filter __proto__/constructor/prototype as keys.
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.
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.
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.
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.
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).

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

SCAN MY APP