Rolling Out Trusted Types in a Legacy App

Most DOM-based vulnerabilities come down to one shape: a string containing attacker-influenced data reaches a sink that parses it as markup or code. Reviewing every such assignment forever does not scale, and the assignments are added faster than they are audited. Trusted types invert the problem — the browser refuses a plain string at those sinks entirely, so the only way to reach one is through a policy you wrote and can review in a single file.

This guide runs the rollout on an application that was not built for it: enumerate the real sinks with report-only mode, route the legitimate ones through a sanitising policy, give the rest a named and deliberately embarrassing escape hatch, then enforce. It is part of the DOM-Based Vulnerability Sanitization guide within Vulnerability Patterns & Web Mitigation Strategies.

Prerequisites

  • The ability to set response headers per environment
  • A violation collection endpoint — the same one used for the CSP rollout
  • A sanitiser that can return the required type, or a wrapper around one
  • A metrics pipeline able to count calls to a named policy

Expected Outcomes

  • A complete list of the sinks your application actually assigns to
  • One sanitising policy that legitimate rendering paths use
  • A named legacy policy with a call count that trends downward
  • Enforcement enabled, with reports retained as a detection signal

Step 1: Find the Real Sinks With Report-Only Mode

Content-Security-Policy-Report-Only:
  require-trusted-types-for 'script';
  trusted-types app-sanitizer;
  report-uri https://reports.example.com/tt

Static analysis finds the sinks in your own code. Report-only mode finds the ones inside dependencies, in code paths that only run for certain users, and in the analytics tag someone added last quarter — which is why it is worth running even when you think you already have the list.

// Group reports by sink and source file: the shape of the work becomes obvious quickly.
const summary = reports.reduce((acc, r) => {
  const key = `${r['sample']?.split('|')[0] ?? 'unknown'} @ ${r['source-file'] ?? 'unknown'}`;
  acc[key] = (acc[key] ?? 0) + 1;
  return acc;
}, {});
// Typical output on a mature application:
//   Element innerHTML @ /bundle.js            4812   → the rich text renderer, one call site
//   Element innerHTML @ /vendor/legacy.js      301   → a dependency, needs a wrapper
//   HTMLScriptElement src @ /bundle.js          64   → dynamic import, safe by construction
//   Element outerHTML @ /vendor/widget.js        9   → replace this widget

The distribution is nearly always the same: one or two call sites account for the overwhelming majority, and a scattering of single-digit counts hides in dependencies. Fixing the two big ones is most of the work.

The Work Is Concentrated, and the Rest Is in Dependencies Violations grouped by sink and source file. A single rich-text renderer accounts for the large majority. A dependency contributes a smaller but significant share. Dynamic script imports are safe by construction and need only a pass-through. A handful of single-digit counts sit in third-party widgets, which usually get replaced rather than wrapped. Violations by call site, one week of report-only mode innerHTML in the rich text renderer — 4812 · one call site, route through the policy innerHTML inside a dependency — 301 · wrap it, or replace it script src for dynamic imports — 64 · safe by construction, pass through outerHTML in a third-party widget — 9 · usually cheaper to replace the widget than to wrap it

Step 2: Write One Sanitising Policy

// policy.ts — the only place a string becomes assignable to a DOM sink.
import DOMPurify from 'dompurify';

export const appPolicy = trustedTypes.createPolicy('app-sanitizer', {
  createHTML: (input: string) =>
    DOMPurify.sanitize(input, {
      RETURN_TRUSTED_TYPE: false,
      FORBID_ATTR: ['id', 'name'],        // also closes the clobbering path
      FORBID_TAGS: ['form', 'base'],
    }),

  createScriptURL: (input: string) => {
    const url = new URL(input, location.origin);
    if (url.origin !== location.origin && !ALLOWED_SCRIPT_ORIGINS.has(url.origin)) {
      throw new TypeError(`refused script URL: ${url.origin}`);
    }
    return url.toString();
  },

  // createScript is deliberately absent: nothing in this application should build script text.
});

Rendering paths then use it explicitly, and the call site reads as what it is:

element.innerHTML = appPolicy.createHTML(userSuppliedMarkup);

Omitting createScript entirely is a decision worth making consciously. If no legitimate code needs to construct executable script text, leaving the function undefined means any attempt to do so throws — which is a much stronger statement than a function that sanitises and hopes.


Step 3: Give Legacy Code a Named, Shrinking Exception

// Deliberately unflattering name: it shows up in every report and every review.
export const legacyPolicy = trustedTypes.createPolicy('legacy-unsafe', {
  createHTML: (input: string) => {
    metrics.increment('trusted_types.legacy_unsafe', { stack: shortStack() });
    return input;                         // no sanitisation — this is the debt marker
  },
});
# Both policies named in the directive; nothing else may create one.
Content-Security-Policy-Report-Only:
  require-trusted-types-for 'script';
  trusted-types app-sanitizer legacy-unsafe;
  report-uri /tt

The metric is the mechanism. Put the legacy call count on the dashboard next to the error rate, review it monthly, and the number falls — because a visible number with a name attached gets attention in a way that a scattered pattern never does. When it reaches zero, remove the policy from the directive so it cannot come back quietly.

What Makes the Legacy Count Actually Fall A named policy makes every legacy call visible in reports. A counter with an owner puts it on a dashboard beside the error rate. A monthly review turns it into a decision rather than a background fact. And a ratchet in continuous integration prevents the number from rising quietly between reviews. A deliberately unflattering policy name it appears in every report and every review, which is the point A counter with a named owner a visible number with a name attached gets attention; a scattered pattern does not A monthly review of the count turns a background fact into a decision somebody makes A ratchet in continuous integration fails the build if the count rises, so progress is not quietly reversed

Step 4: Enforce, and Keep Reporting

Content-Security-Policy:
  require-trusted-types-for 'script';
  trusted-types app-sanitizer legacy-unsafe;
  report-uri /tt

After the switch, every violation report is meaningful: either a code path nobody exercised during the observation window, or an actual attempt to reach a sink with attacker-controlled data. Both deserve investigation, which is a considerable improvement over the situation before the rollout, where neither was visible at all.

The Audit Surface Before and After Before enforcement, any string from anywhere in the application or its dependencies can reach a dangerous sink, so the audit surface is every assignment in the codebase, forever. After enforcement, the only way to reach a sink is through a named policy, so the audit surface is one file plus a counted legacy exception. Before any string, from anywhere, may reach a sink including from inside dependencies new assignments arrive faster than reviews Audit surface: every assignment in the codebase, indefinitely — which is why the audit is never actually finished. After only a named policy may produce a sink value dependencies must go through it too the legacy exception is named and counted Audit surface: one policy file, plus a number on a dashboard that goes down — a review that can actually be completed.

Verification

// 1. A plain string is refused at an enforced sink.
it('refuses a raw string at innerHTML', () => {
  expect(() => { document.body.innerHTML = '<b>x</b>'; }).toThrow(TypeError);
});

// 2. Policy output is accepted, and is sanitised.
it('accepts policy output and strips script', () => {
  document.body.innerHTML = appPolicy.createHTML('<img src=x onerror=alert(1)><b>ok</b>');
  expect(document.body.innerHTML).toContain('<b>ok</b>');
  expect(document.body.innerHTML).not.toContain('onerror');
});

// 3. An unnamed policy cannot be created.
it('refuses to create an unlisted policy', () => {
  expect(() => trustedTypes.createPolicy('sneaky', { createHTML: (s) => s })).toThrow();
});

// 4. The legacy counter is trending down.
//    Assert in CI that the count is no higher than last release's recorded value.
# 5. Confirm the enforced header is present in production and names only the expected policies.
curl -sI https://app.example.com/ | grep -i 'content-security-policy:' | tr ';' '\n' | grep trusted-types

Test four is the one that keeps the rollout from stalling: without a ratchet, the legacy count stops falling the moment attention moves elsewhere.


Troubleshooting

Symptom Likely cause Fix
A dependency throws at startup after enforcement It assigns to a sink internally Wrap its entry point, patch it, or replace it — the report names the file
The default policy is being used implicitly A policy named default exists Remove it; implicit fallbacks defeat the audit surface benefit
Framework rendering breaks Framework needs its own policy name Add the framework’s documented policy name to the directive
Violations continue after every fix Report-only observation window too short Extend it to cover a full traffic cycle, including infrequent paths
Legacy count stops falling No owner and no ratchet Assign the metric an owner and fail CI when it rises
Server-rendered markup triggers violations Hydration assigning markup into a sink Route hydration through the same policy, or render text nodes

Common Implementation Mistakes


Frequently Asked Questions

What does this give me beyond a content security policy?

A script policy governs which scripts may execute; trusted types govern what may reach a DOM sink at all. That closes exactly the DOM-based cases a script policy misses — attacker-controlled markup assigned into an innerHTML sink from your own, already-allowed bundle. It also converts an unbounded review problem into a bounded one: instead of auditing every assignment forever, you review one policy file and watch one counter.

Does the legacy escape hatch not defeat the point?

Only if it stays the same size. Its purpose is to make the rollout achievable without a rewrite, and its value comes from being named, counted and shrinking. A dozen call sites routed through a policy called legacy-unsafe, with a metric on the dashboard and an owner, is a tractable list with a trajectory. The same dozen scattered anonymously through the codebase is precisely the situation you started in.

What about browsers that do not support it?

They ignore the directive: nothing breaks, and nothing is enforced there. That makes this a defence-in-depth measure rather than a universal control, and it is still worth the effort — the rollout forces every sink through one reviewed policy, which improves the code on every browser regardless of support. Keep contextual encoding and sanitisation as the primary control either way.