Skip to content

Trusted Types report-only pilot (fizzy CSP telemetry) - #3092

Draft
jeremy wants to merge 1 commit into
mainfrom
security/trusted-types-report-only-pilot
Draft

jeremy wants to merge 1 commit into
mainfrom
security/trusted-types-report-only-pilot

Conversation

@jeremy

@jeremy jeremy commented Aug 31, 2026

Copy link
Copy Markdown
Member

Trusted Types report-only pilot (XSS counteroffensive — Tier C)

Report-only telemetry pilot only — the greenlit option 1 from the Security Hardening card. Adds Trusted Types violation reporting to fizzy with zero enforcement and zero app-code migration, so we get real signal on which DOM sinks write raw strings before committing to the enforcement design.

What ships

A new TrustedTypesReportOnly Rack middleware in config/initializers/content_security_policy.rb emits a separate Content-Security-Policy-Report-Only header on HTML responses carrying:

require-trusted-types-for 'script'

plus report-uri <uri> when a report-uri is configured (reusing the existing CSP_REPORT_URI / config.x.content_security_policy.report_uri knob).

Why a separate header, not the app policy

Fizzy ships a single Rails-managed CSP whose whole header flips between enforcing and report-only via one report_only flag (default: enforcing). Adding the Trusted Types directive into that policy would put it in the enforcing header wherever fizzy runs enforcing — and, with no named createHTML policy registered, every one of the un-migrated first-party sinks would throw on Chromium. That is the parked enforcement phase, not this PR.

So Trusted Types rides its own always-report-only header instead:

  • The enforcing Content-Security-Policy header is byte-for-byte unchanged.
  • Report-only can only report, never block — nothing breaks on any browser. Non-Chromium browsers ignore the directive entirely.
  • The middleware is inserted outside ActionDispatch::ContentSecurityPolicy::Middleware, so when a deployment already runs the app policy as report-only, the Trusted Types policy stacks as a second report-only policy (newline-joined → two header lines) rather than clobbering it. Each reports independently.

Why only require-trusted-types-for 'script' (no trusted-types allowlist)

The goal is sink-write telemetry. require-trusted-types-for 'script' is what makes a raw-string write to a DOM sink surface as a violation report. A trusted-types <policy-name> allowlist does something different — it restricts which policy names may be created — and since this pilot registers no named createHTML policy, adding an allowlist would only generate policy-creation noise (including from lexxy's isolated DOMPurify policy). Omitting it leaves policy creation unrestricted, which is exactly right for report-only.

Verification

  • New test in test/integration/static_csp_test.rb: the enforcing CSP carries no require-trusted-types-for; a report-only header carries require-trusted-types-for 'script'; no trusted-types allowlist is emitted.
  • Middleware behavior confirmed across three shapes: enforcing main policy (enforcing header untouched, TT rides its own RO header), main policy itself report-only (existing RO policy preserved, TT stacked as a second policy), and non-HTML responses (skipped).

Parked follow-ons (Avenue B — the enforcement phase)

Everything below stays parked, gated on the shared sanitize() chokepoint landing:

  1. Enforcement — register the single named createHTML → sanitize() policy and flip to enforcing, only after the report-only window enumerates every real sink and the 6 first-party fizzy sinks are migrated onto the chokepoint. Human sign-off required; not auto-PR-eligible.
  2. bc3 / haystack — template the same ramp once fizzy proves it.
  3. trix / lexxy — the library re-inflation boundaries ship the policy hook last so consumers opt in per app.
  4. Open design question — lexxy deliberately runs its own isolated DOMPurify instance, so the design doc's "one named policy per app vs. a shared vendored policy" question is live for the enforcement phase. This pilot is unaffected — report-only registers no policy.

Design doc: trusted-types.md.

Ship require-trusted-types-for 'script' on a separate, always-report-only
Content-Security-Policy-Report-Only header so it collects sink-write
violations without touching the enforcing policy. Report-only can only
report, never block, so no un-migrated DOM sink throws on Chromium; other
browsers ignore the directive.

No named createHTML policy is registered and no trusted-types allowlist is
emitted — this is telemetry only. Enforcement (the single sanitize()
chokepoint), bc3/haystack, and the trix/lexxy policy hooks stay parked on
Avenue B.

A dedicated middleware, inserted outside the Rails CSP middleware, stacks
the directive as an additional report-only policy so it never clobbers an
app policy that is itself report-only.
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