What does the CSP directive require-trusted-types-for 'script' change about DOM sinks such as innerHTML, and what job does a policy created with trustedTypes.createPolicy() do?
answer
- make the unsafe write impossible
- sinks demand a branded type
- only a policy can mint one
- the browser never reads the string
- a policy named default fires implicitly
basics
~20 sWith require-trusted-types-for 'script' in force, DOM sinks stop accepting plain strings and throw a TypeError; they take only TrustedHTML, TrustedScript or TrustedScriptURL objects. Only a policy registered through trustedTypes.createPolicy() can mint those, so every unsafe write is funnelled through reviewable code.
solid answer
~40 sThe directive flips the sinks from permissive to fail-closed. Assigning a string to `innerHTML`, `outerHTML`, `script.src` or calling `eval()` throws a `TypeError` instead of executing; the sinks now accept only the typed objects `TrustedHTML`, `TrustedScript` and `TrustedScriptURL`. Those cannot be constructed directly — `trustedTypes.createPolicy(name, { createHTML, createScript, createScriptURL })` returns a policy whose functions are the *only* minting path, and the `trusted-types` directive lists which policy names may exist. The security value is not sanitization; the policy body still has to sanitize. It is that an unbounded audit of every sink in the codebase becomes a bounded audit of a handful of policy functions, enforced by the runtime rather than by code review. The trap is the implicitly-invoked policy named `default`: if it returns its input unchanged, every sink is silently reopened.
code
javascript · 14 linesimport DOMPurify from 'dompurify';
// Feature-detect: code stays identical on engines without Trusted Types.
const policy = window.trustedTypes
? window.trustedTypes.createPolicy('app-html', {
createHTML: (input) => DOMPurify.sanitize(input),
})
: { createHTML: (input) => DOMPurify.sanitize(input) };
// Accepted under `require-trusted-types-for 'script'`.
document.querySelector('#bio').innerHTML = policy.createHTML(userHtml);
// Throws a TypeError under enforcement: a bare string is no longer a valid value.
// document.querySelector('#bio').innerHTML = userHtml;go deeper
Know that this is a browser feature switched on by a Content-Security-Policy directive, and that once on, writing a plain string to innerHTML raises an error instead of running.
Explain the mechanics: sinks accept only TrustedHTML, TrustedScript or TrustedScriptURL; those are unforgeable and can be produced only by a function registered through trustedTypes.createPolicy(), whose name the trusted-types directive controls.
Demonstrate the operational judgment — inventory sinks in report-only mode first, keep the policy count tiny, refuse a pass-through default policy, and articulate that the win is a bounded audit surface rather than any sanitization the browser performs.
Own the tradeoff: this converts an unbounded, decaying review obligation into a small enforced one, at the cost of a migration touching every markup write and a feature not enforced on every engine. Be ready to defend that as defence in depth and to state the burn-down plan.
## The problem it solves Sanitizing correctly at every sink is a discipline problem, and discipline does not survive a large codebase over years. You can grep for `innerHTML` today; you cannot grep for the one a dependency added last week, or the one a refactor introduced through a helper. Trusted Types changes the shape of the problem: instead of proving that every dangerous write is safe, you make dangerous writes *impossible* except through a small set of named functions, and then you only have to review those. ## Turning the directive on It is a Content-Security-Policy feature: ``` Content-Security-Policy: require-trusted-types-for 'script'; trusted-types app-html sanitizer ``` - `require-trusted-types-for 'script'` is the enforcement switch. Injection sinks stop accepting strings. - `trusted-types <names>` restricts which policy *names* may be created. `'none'` forbids all policy creation; `'allow-duplicates'` permits the same name to be registered more than once (useful while several bundles coexist, and worth removing afterwards). Once enforced, `el.innerHTML = someString` throws a `TypeError`. So does `script.src = url`, `el.outerHTML = s`, `el.insertAdjacentHTML(pos, s)`, `document.write(s)`, `eval(s)` and `new Function(s)`. The sinks now demand a typed object. ## What a policy is ```js const policy = trustedTypes.createPolicy('app-html', { createHTML: (input) => DOMPurify.sanitize(input), createScriptURL: (input) => { const u = new URL(input, document.baseURI); if (u.origin !== location.origin) throw new TypeError('off-origin script'); return u.href; }, }); el.innerHTML = policy.createHTML(userHtml); // a TrustedHTML: accepted ``` `createPolicy` returns a `TrustedTypePolicy`. Calling `policy.createHTML(str)` runs your function and wraps its return value in a `TrustedHTML` object. There is no constructor for these types and they are not forgeable, so the branded object is proof that *some* policy function saw the value. Note what the browser does **not** do: it never inspects the string. If your `createHTML` is the identity function, the browser hands the raw payload straight to the parser. Trusted Types enforces *where* the decision is made, not *what* it decides. The sanitizing still belongs to you — which is why the policy body typically wraps a real sanitizer. ## The default policy trap A policy registered with the exact name `default` is invoked implicitly whenever a plain string reaches a sink. This is the migration escape valve: legacy code keeps running while you convert call sites. It is also the single most common way a Trusted Types deployment ends up worthless, because the tempting default policy is: ```js trustedTypes.createPolicy('default', { createHTML: (s) => s }); // reopens every sink ``` If you use one at all, make it sanitize, log loudly with enough stack context to find the call site, and treat its call volume as a burn-down metric with a date on it. ## What it buys, concretely - **A bounded audit surface.** Security review moves from "every DOM write in the app and its dependencies" to "these three policy functions." - **Runtime enforcement, not convention.** A new sink introduced by a new dependency fails immediately rather than shipping quietly. - **A real signal during rollout.** Running the directive in report-only mode first produces violation reports and `SecurityPolicyViolationEvent`s that enumerate exactly which sinks the app actually exercises — usually far fewer than the grep suggested. ## What it does not buy - It is a **`'script'` guard**, so it covers the injection sinks. It says nothing about a `javascript:` URL you assign to `a.href`, which is a navigation sink, not a script sink; CSP's script directives are what constrain that. - It cannot fix a bad policy. A permissive `createHTML` is a codebase-wide hole in one line. - It does not remove the sanitizer dependency, and it does not audit server-rendered markup. ## Support and how to treat it Trusted Types shipped in Chromium (Chrome 83) and other engines have added support more recently, so coverage is not universal across every browser your users run. That makes it **defence in depth**: on engines that enforce it, an entire bug class is structurally closed; on the others, your sanitizer and your review are still what stand. Because a policy call is a plain function call, code written for Trusted Types runs unchanged where the feature is absent — you feature-detect `window.trustedTypes` when creating the policy and fall through to the raw string otherwise. That asymmetry is acceptable precisely because it costs nothing where the feature is missing. ## Rolling it out The realistic order is: report-only first to inventory real sinks; convert the genuine markup sites to explicit policy calls; add a permissive-but-logging `default` policy to keep the long tail alive; drive the default policy's call count to zero; then enforce and delete the default. The work is proportional to the number of distinct sinks, which is why the report-only inventory step comes first.
- Does Trusted Types sanitize anything by itself?No. The browser never inspects the string; it only checks that the value carries the `TrustedHTML`, `TrustedScript` or `TrustedScriptURL` brand, which proves a policy function produced it. If your `createHTML` returns its input unchanged, the raw payload reaches the parser. The mechanism enforces *where* the decision happens; the sanitizing is still your policy body's job.
- Why is a policy named 'default' dangerous?Because it is invoked implicitly for every plain string that reaches a sink, which is exactly the legacy code you were trying to constrain. A default policy that returns its input restores the original behaviour app-wide in one line. If you need one during migration, make it sanitize, log the call site, and treat its call count as a burn-down metric to zero.
- How would you turn this on without breaking a large existing app?Start in report-only mode to inventory which sinks the app truly exercises — the reports and SecurityPolicyViolationEvents usually list far fewer than a grep suggests. Convert genuine markup sites to explicit policy calls, cover the long tail with a logging default policy, drive its call count to zero, then enforce and delete the default.
- Does it stop a javascript: URL assigned to a.href?No. `require-trusted-types-for 'script'` covers the injection sinks — innerHTML, outerHTML, insertAdjacentHTML, document.write, script.src, eval and the Function constructor. Navigation sinks such as `a.href` and `location.href` are not in that set, so they still need their own parse-and-allowlist check, with CSP's script directives as the browser-level backstop.
saying these in an interview costs you the question
- "Trusted Types sanitizes the markup for you"
- "A default policy returning the input is a safe migration step"
- "It replaces the need for a sanitizer entirely"
- "Enforcement is silent; bad writes are just dropped"
- "It also blocks javascript: URLs in href"