Enforcing Trusted Types in Extension Pages
Add Trusted Types to MV3 popups, options pages and side panels: the require-trusted-types-for CSP directive, a single sanitising policy, fixing violations, framework compatibility and report-only rollout.
Table of Contents
Code review catches most innerHTML with untrusted data, but not all — especially in dependencies, in code paths nobody tests, and in the refactor six months from now. Trusted Types turns the rule “never put an unsanitised string into an HTML sink” into something the browser enforces: with the right CSP directive, assigning a plain string to innerHTML, outerHTML, insertAdjacentHTML, document.write, script src and similar sinks throws a TypeError, and only values created by a policy you define are accepted. For extension pages, which run with all of the extension’s privileges, that is a valuable second line of defence. This guide rolls it out without breaking the extension. It belongs to extension security and CSP hardening.
How Trusted Types works
Trusted Types adds typed wrappers — TrustedHTML, TrustedScript, TrustedScriptURL — and policies that create them. When a page’s CSP contains require-trusted-types-for 'script', the browser rejects plain strings at injection sinks: el.innerHTML = str throws, while el.innerHTML = policy.createHTML(str) works, because the policy’s createHTML function ran (and presumably sanitised) the string. The trusted-types directive lists which policy names may be created, so a dependency cannot quietly create its own permissive policy. Safe APIs — textContent, setAttribute for non-URL attributes, createElement — are unaffected, which is why code that already follows good habits needs few changes.
Step-by-step: rolling out Trusted Types
1. Start in report-only mode
1<!-- options.html, during rollout -->
2<meta http-equiv="Content-Security-Policy-Report-Only"
3 content="require-trusted-types-for 'script'; trusted-types readable-sanitizer">
Execution context: an extension page. Report-only mode logs a violation to the console for every plain string reaching a sink, without blocking anything. Use the browser’s console (and, if you like, a securitypolicyviolation event listener that records violations in development) to collect the list of sinks to fix. A <meta> policy affects only the page it is in, which makes per-page rollout easy; the manifest’s extension_pages CSP applies to all pages at once.
2. Create one sanitising policy
1// shared/trusted.js
2import DOMPurify from "dompurify";
3
4export const policy = globalThis.trustedTypes?.createPolicy("readable-sanitizer", {
5 createHTML: (input) => DOMPurify.sanitize(input, { RETURN_TRUSTED_TYPE: false }),
6}) ?? { createHTML: (input) => DOMPurify.sanitize(input) }; // engines without Trusted Types
Execution context: a module imported by every extension page. One policy, with a fixed name listed in the CSP, is the only way to produce TrustedHTML in your pages. Because it sanitises, using it is always safe. The fallback object keeps the same calling code working in engines that lack Trusted Types (Firefox, Safari) — there, sanitisation still happens, it just is not enforced by the browser. DOMPurify can also return TrustedHTML itself when configured with RETURN_TRUSTED_TYPE: true.
3. Fix each reported sink
1// Before
2list.innerHTML = items.map((i) => `<li>${i.title}</li>`).join("");
3
4// After — no HTML parsing at all
5list.replaceChildren(...items.map((i) => Object.assign(document.createElement("li"), { textContent: i.title })));
6
7// Before — rich excerpt
8excerpt.innerHTML = item.excerptHtml;
9// After
10excerpt.innerHTML = policy.createHTML(item.excerptHtml);
Execution context: extension pages. Most violations disappear by switching to DOM construction and textContent, which is safer than any sanitiser. Route only genuinely rich content through the policy. Each fix removes a potential XSS sink regardless of whether Trusted Types ends up enforced.
4. Make frameworks cooperate
1// React: pass TrustedHTML through dangerouslySetInnerHTML
2<div dangerouslySetInnerHTML={{ __html: policy.createHTML(excerpt) }} />
Execution context: an extension page built with a framework. Modern React, Vue and Angular builds avoid string-based sinks in their own rendering paths, and their raw-HTML features accept TrustedHTML values. Some older libraries and component kits assign strings to innerHTML internally; report-only mode surfaces them. Replace them, configure them to use a policy if they support it, or — as a last resort — allow a narrowly named second policy for that library in the trusted-types directive.
5. Enforce through the manifest
1{
2 "content_security_policy": {
3 "extension_pages": "script-src 'self'; object-src 'self'; base-uri 'none'; require-trusted-types-for 'script'; trusted-types readable-sanitizer"
4 }
5}
Execution context: the manifest. Once report-only runs are clean across all pages and tests, move the directives into the enforced policy for every extension page. Chrome applies it to the popup, options page, side panel, offscreen documents and any other extension page. The service worker has no DOM sinks, so the directive is irrelevant there. Engines that do not implement Trusted Types ignore the directives harmlessly.
6. Catch regressions in tests
1// e2e: fail the test on any Trusted Types violation
2page.on("console", (m) => { if (/TrustedHTML|Trusted Type/.test(m.text())) throw new Error(m.text()); });
Execution context: an end-to-end test against extension pages. With enforcement on, a new unsanitised sink throws at runtime — but only on the code path that reaches it. Running the full UI test suite with a console listener turns those runtime errors into test failures before release.
7. Keep content scripts in mind separately
Trusted Types enforcement through the extension CSP applies to extension pages, not to content scripts, which run under the page’s own policy in the isolated world. Content-script UI should follow the same habits — text APIs and a sanitiser — and, where it renders into a shadow root you control, you can still use a Trusted Types policy object for consistency if the engine supports it.
Common mistakes
- Enforcing before collecting violations. Pages break in ways users find first.
- Many permissive policies. A policy that returns its input unchanged defeats the purpose.
- Policy names not listed in
trusted-types. Creating them throws. - Assuming Firefox and Safari enforce it. They do not yet; keep sanitising regardless.
- Fixing violations with
policy.createHTMLeverywhere. Prefer text APIs; reserve the policy for rich content.
Cross-browser variation
- Chrome / Edge: full Trusted Types support; the manifest CSP directives are enforced on extension pages.
- Firefox: Trusted Types support has been in development; the directives are ignored where unsupported, so the fallback policy keeps behaviour consistent.
- Safari: support has been limited; treat Trusted Types as Chrome-side enforcement of a discipline every build follows.
Verification
- With enforcement on, run
document.body.innerHTML = "<b>x</b>"in a page’s console and confirm aTypeError. - Exercise every page and feature; confirm no violations appear.
- Render a saved item with
<img onerror>in its excerpt and confirm the sanitised output contains no handler. - Confirm the extension still works in Firefox and Safari with the fallback policy.
FAQ
Does Trusted Types replace sanitisation?
No. It enforces that sanitisation happened through your policy. The policy still has to sanitise correctly.
What about eval and new Function?
The MV3 CSP already blocks them in extension pages; Trusted Types’ TrustedScript covers the remaining script sinks.
Is there a performance cost?
Negligible. The sanitiser itself costs time; Trusted Types only checks the value’s type.
Can a dependency bypass the policy?
Only by creating its own policy, which the trusted-types allow-list prevents unless you list its name. That is the point of naming policies: any new one is a deliberate decision recorded in the manifest.
Do offscreen documents need anything special?
They are extension pages and receive the same CSP. If an offscreen document parses HTML, route insertion through the policy like any other page.
How do I debug a TypeError from a third-party component?
The stack trace points at the sink assignment inside the library. Check whether the library offers a Trusted Types option; otherwise wrap the component, replace it, or give it a separately named policy that sanitises.
Related
- Preventing XSS in extension pages — the habits Trusted Types enforces.
- Writing a strict content security policy for MV3 — where the directive lives.
- Fixing CSP violations in extension pages — reading violation reports.
- Extension security and CSP hardening — the parent topic.