Web Platform · Web Security Model

Trusted Types and the Typing of DOM XSS Sinks

How require-trusted-types-for turns string assignment into a checked operation, what a policy must do beyond returning its input, and how the enforcement spreads across a codebase without freezing releases.

Server-side output encoding fails at exactly one place: when the data reaches the DOM through an API that interprets a string as markup or code. element.innerHTML = userInput is not an encoding bug, it is a type error that the platform historically permitted. Trusted Types makes that interpretation explicit by changing the type of the sink, so that only a value produced by a registered policy can be assigned. It converts DOM cross-site scripting from a discipline problem into a set of compile-time-ish failures at runtime, and its value depends entirely on what the policies do.

Sinks are the whole attack surface

A DOM XSS sink is any API that turns a string into markup, script, or URL. The main groups:

  • HTML parsers: innerHTML, outerHTML, insertAdjacentHTML, document.write, Range.createContextualFragment, iframe.srcdoc.
  • Script text and execution: eval, Function, setTimeout and setInterval with a string argument, script.text, navigations to javascript: URLs, and event handler content attributes set through setAttribute.
  • Script URLs: script.src, the Worker and SharedWorker constructors, and importScripts.

A source is anything attacker-influenced: location.hash, location.search, document.referrer, postMessage payloads, values read from storage that were written from those, and server data that was encoded for the wrong context. The bug is always the join between a source and a sink, and no amount of care at the source end fixes a sink that accepts untyped strings.

Trusted Types covers the sinks, not the sources. Under enforcement, assigning a plain string to innerHTML throws a TypeError; assigning a TrustedHTML object succeeds. The only way to obtain a TrustedHTML from a string is trustedTypes.createPolicy(...) followed by the policy’s createHTML method. Every piece of HTML that reaches a parser therefore passes through code that someone wrote deliberately and that can be reviewed, tested, and counted.

Policies are the trust boundary

A policy is created with a name and a set of callbacks:

const sanitizer = trustedTypes.createPolicy('app-html', {
  createHTML: (input) => DOMPurify.sanitize(input, { RETURN_TRUSTED_TYPE: true }),
  createScriptURL: (input) => {
    const u = new URL(input, location.origin);
    if (u.origin !== location.origin) throw new TypeError('cross-origin script');
    return u.href;
  },
});

The callback types map to value types. createHTML returns TrustedHTML, consumed by parsers. createScript returns TrustedScript, consumed by eval and string-timer paths. createScriptURL returns TrustedScriptURL, consumed by script and worker URL sinks. A fourth, createParserOptions, vets the sanitizer options that the Sanitizer API receives. A policy may implement any subset; a call to a missing method throws.

createScriptURL is where the strongest available invariant lives, because there is no safe way to sanitise a URL into one that is guaranteed not to be javascript:. The correct implementation parses the input with the URL constructor, checks the protocol and origin against an allowlist, and returns the normalised href. String-prefix checks fail: javascript:alert(1) has no prefix in common with https://, and a check for https: at the start is defeated by leading whitespace and control characters that the parser strips. Parsing is the only comparison that matches what the browser will do.

Policies are registered once per realm and are retrieved by name. trustedTypes.createPolicy with a name that already exists throws unless the trusted-types CSP directive includes 'allow-duplicates', which exists because some libraries create their own policy at load time and would otherwise fail on a second load. The default policy is a special case: if trustedTypes.defaultPolicy is set, the browser calls its createHTML or createScriptURL automatically when a string is assigned to a sink, which is precisely the wrong shape for enforcement, since it removes the requirement that call sites be changed. A default policy is useful during migration and counterproductive afterwards.

The CSP directives that make typing mandatory

Without CSP, Trusted Types is advisory: the API exists, policies can be created, and the DOM still accepts strings. Enforcement comes from two directives.

require-trusted-types-for 'script' makes the sinks type-checked. Enforcement is per realm, decided by the policy of the document that owns the realm; a child document with a URL of its own is governed by its own response headers, while a local-scheme frame such as about:blank or srcdoc inherits its parent’s policy container along with the directive. trusted-types restricts which policy names may be created, using a list of names, the keyword 'none' to forbid policy creation entirely, and 'allow-duplicates' to permit re-creation. A directive of trusted-types app-html means only a policy named app-html can exist, which prevents an injected script from calling createPolicy('x', {createHTML: s => s}) and laundering arbitrary strings through a policy of its own.

Content-Security-Policy: require-trusted-types-for 'script'; trusted-types app-html app-script-url

Both directives support report-only delivery through Content-Security-Policy-Report-Only, which produces violation reports instead of exceptions. That is the only practical way to inventory sinks across an existing application, because the violation reports name the document, the blocked value’s type, and the sink that would have thrown.

The trusted-types allowlist is not a security boundary against code already running in the page — such code can use any policy that exists, including app-html. The boundary is that all HTML must pass through some registered policy, so auditing reduces to auditing the policies rather than auditing every call site. That is a much smaller surface, and it is stable under refactoring, which per-call-site discipline is not.

Laundering is the failure mode

A policy that returns its input unchanged provides no protection beyond the type annotation:

trustedTypes.createPolicy('legacy', { createHTML: (s) => s });

Every sink that uses it still parses attacker-controlled markup; the only thing that changed is that the assignment no longer throws. This policy shape appears during migrations because it makes tests pass, and it survives because nothing fails. Reviewing policies for this pattern, and refusing names like legacy or raw in the trusted-types allowlist, is the difference between enforcement and decoration.

Sanitisation inside a policy must be done by a real HTML sanitiser with an allowlist of elements and attributes, not by regular expressions. Regular-expression filtering of HTML fails on nested tags, on attribute quoting variations the parser normalises differently, on namespace confusion in foreign content, and on mutation XSS where the browser’s parser produces a different tree than the sanitiser’s model. The Sanitizer API’s Element.setHTML performs sanitisation in the parser itself and is a better primitive where available; a library sanitiser that returns a trusted value is the practical alternative.

Two more laundering routes deserve attention. A sink reached through a different realm — a DOMParser-created document, an about:blank iframe, a shadow root created before enforcement — may not be covered by the document’s policy, so content can be parsed in an unguarded realm and then moved. And a policy that sanitises HTML but hands the result to insertAdjacentHTML in a context where the parser resumes inside an attribute, such as a <table> or a <select>, can produce markup that the sanitiser’s parse tree did not anticipate. Contextual sinks need contextual handling; Trusted Types types the value, not the context.

Rolling it out alongside the rest of the policy

Enforcement is usually staged: report-only first to collect sink locations, then a small allowlist of policy names, then require-trusted-types-for 'script' in enforcement with the policies in place. Libraries that assign strings internally — templating engines, rich text editors, mapping libraries — either need versions that accept trusted values or need wrappers that call the application’s policy at the library boundary. A wrapper around a library’s string API reintroduces the reviewed policy as the only path, which is acceptable if the wrapper does not expose a raw string path beside it.

Trusted Types composes with the rest of the policy rather than replacing it. A nonce-based script-src still decides which script elements may execute; Trusted Types decides which assignments to script.src and innerHTML are legal. require-trusted-types-for 'script' does nothing about an injected <script> element created by a trusted script, and a nonce does nothing about an innerHTML assignment in trusted code. The two mechanisms cover different halves of injection, and the details of the script half are in CSP source lists, nonces, and trust propagation.

The remaining gap is architectural. Trusted Types protects the document that carries the policy. An isolated widget in a sandboxed frame has its own policy and its own sinks, and a message it receives is attacker-influenced input like any other; sandboxed frames and the postMessage trust boundary covers the validation that has to happen before that input reaches a policy at all. Where a policy cannot be written — a third-party script that must be able to build markup from strings — the answer is to move the script’s output into a frame or to a server that returns finished HTML, not to widen the policy until it permits everything.

References

  1. Trusted Types — W3C Web Platform Incubator Community Group
  2. MDN — Trusted Types API — MDN Web Docs
  3. HTML Standard — Dynamic markup insertion — WHATWG