Skip to content

spike(security): ship Trusted Types (report-only) for the search UI#52

Merged
jdevalk merged 3 commits into
mainfrom
spike/trusted-types-search
Jun 23, 2026
Merged

spike(security): ship Trusted Types (report-only) for the search UI#52
jdevalk merged 3 commits into
mainfrom
spike/trusted-types-search

Conversation

@jdevalk

@jdevalk jdevalk commented Jun 23, 2026

Copy link
Copy Markdown
Owner

Makes the site a worked example of /spec/security/trusted-types/ (documented in #51) by actually wiring Trusted Types — starting in report-only so nothing breaks in production.

The finding

Pagefind's search UI is the only thing on the site that would trip Trusted Types. The built pagefind-ui.js assigns innerHTML 11 times and uses no other sink (no eval, document.write, srcdoc); our own scripts use zero DOM-injection sinks. Because Pagefind's bundled code does raw el.innerHTML = …, it can't opt into a named policy — so the auto-applied default policy is the only thing that can cover it, which means a general sanitiser (DOMPurify), not a <mark>-only one.

What this PR does

  • Vendors DOMPurify 3.4.11 as a self-hosted first-party asset at /vendor/purify.min.js (stays under script-src 'self'), copied from node_modules by scripts/generate-assets.mjs at prebuild.
  • Adds public/trusted-types-policy.js registering the default Trusted Types policy backed by DOMPurify.sanitize (strips scripts/handlers, keeps Pagefind's a/p/mark/list markup). Loaded first in <head> so it exists before Pagefind mounts. Fails closed and loud if DOMPurify is absent rather than registering an insecure pass-through.
  • Adds Content-Security-Policy-Report-Only: require-trusted-types-for 'script'; trusted-types default; report-to csp-endpoint to _headers, reusing the existing /reports collector. Report-only = non-blocking.
  • Ignores the vendored minified lib in eslint/prettier.

Build, lint, and format all pass; both files land in dist.

⚠️ Before merge / enforcing — reviewer notes

  1. Behaviour under real enforcement is unverified. npm run preview doesn't apply _headers, so I couldn't drive a live search with Trusted Types enforced. To confirm DOMPurify preserves Pagefind's markup and results still render: npx wrangler pages dev dist, drop the -Report-Only suffix locally, and run a search.
  2. Report-only + a registered default policy partly cancel out — the policy may handle Pagefind's strings before a violation is reported, so report-only will log little. The textbook staged rollout is report-only without the policy to measure, then add the policy when flipping to enforce. This PR ships the working end-state; happy to split the measurement phase out if preferred.
  3. New dependency. DOMPurify is ~28 KB of third-party (self-hosted, first-party-served) code in the trusted path — it nudges the site's "no third-party scripts beyond Plausible" stance. Worth it to be a true worked example, or leave Trusted Types documented-but-not-shipped? Maintainer's call.

To enforce once verified: change Content-Security-Policy-Report-Only → fold the two directives into the main Content-Security-Policy. If this lands, the Trusted Types page should get a "this site ships it" callout (as the reporting-endpoints page has).

🤖 Generated with Claude Code

jdevalk and others added 2 commits June 23, 2026 09:03
…search

Worked-example spike for /spec/security/trusted-types/. Pagefind's search
UI (pagefind-ui.js) builds its results list with innerHTML — 11 assignments,
the only TrustedHTML sink on the site (our own scripts use none). So enforcing
require-trusted-types-for would break search unless a default policy covers it;
a default policy is the only option because Pagefind's bundled code assigns raw
strings and can't opt into a named policy.

- Vendor DOMPurify 3.4.11 as a self-hosted first-party asset (script-src 'self'),
  copied from node_modules by scripts/generate-assets.mjs at prebuild.
- public/trusted-types-policy.js registers the `default` policy backed by
  DOMPurify (strips scripts/handlers, keeps Pagefind's a/p/mark/list markup).
  Loaded first in <head> so it exists before Pagefind mounts; fails closed and
  loud if DOMPurify is missing rather than registering a pass-through.
- _headers: add Content-Security-Policy-Report-Only with require-trusted-types-for
  'script'; trusted-types default, reporting to the existing /reports collector.
  Report-only is non-blocking — safe in prod, and measures real violations.
- Ignore the vendored minified lib in eslint/prettier.

To enforce: drop the `-Report-Only` suffix once reports are clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…policy

Report-only testing surfaced two sinks the static innerHTML grep missed:

- pagefind.js loads its own JS/WASM chunks via a TrustedScriptURL sink, not
  just innerHTML. The default policy now implements createScriptURL (same-origin
  allowlist; script-src 'self' is the backstop) alongside createHTML.
- DOMPurify registers its own Trusted Types policy named "dompurify"; add it to
  the trusted-types allowlist next to default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 23, 2026

Copy link
Copy Markdown

Deploying specification-website with  Cloudflare Pages  Cloudflare Pages

Latest commit: 51623da
Status: ✅  Deploy successful!
Preview URL: https://43dc4e91.specification-website.pages.dev
Branch Preview URL: https://spike-trusted-types-search.specification-website.pages.dev

View logs

@jdevalk

jdevalk commented Jun 23, 2026

Copy link
Copy Markdown
Owner Author

Report-only testing (thanks!) surfaced two sinks my static innerHTML grep missed — both now fixed in d439f18:

  1. pagefind.js uses TrustedScriptURL, not just TrustedHTML — its core loads its own JS/WASM chunks by assigning a script URL. The default policy now implements createScriptURL (same-origin allowlist; script-src 'self' is the backstop) alongside createHTML.
  2. DOMPurify registers its own policy named dompurify, which the trusted-types default allowlist blocked. Added it: trusted-types default dompurify.

The TrustedHTML/innerHTML path was already silent in your logs, which confirms the createHTML half works. Please re-run the same enforce/report-only test — the three violations above should be gone. If any new TrustedScript (eval/Function) violations show up, that's the next gap to cover.

… into spike/trusted-types-search

# Conflicts:
#	package-lock.json
#	package.json
@jdevalk jdevalk merged commit caf2052 into main Jun 23, 2026
8 checks passed
@jdevalk jdevalk deleted the spike/trusted-types-search branch June 23, 2026 11:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant