Skip to content

Claim a "lexxy" Trusted Types policy instead of racing the host for "dompurify" - #1232

Draft
jeremy wants to merge 3 commits into
sanitizer-instance-and-per-editor-allowlistfrom
trusted-types-policy
Draft

Claim a "lexxy" Trusted Types policy instead of racing the host for "dompurify"#1232
jeremy wants to merge 3 commits into
sanitizer-instance-and-per-editor-allowlistfrom
trusted-types-policy

Conversation

@jeremy

@jeremy jeremy commented Aug 11, 2026

Copy link
Copy Markdown
Member

Consumer action

If your app enforces require-trusted-types-for 'script', add lexxy to your
trusted-types directive.

Content-Security-Policy: require-trusted-types-for 'script'; trusted-types dompurify lexxy
# config/initializers/content_security_policy.rb
policy.trusted_types :dompurify, :lexxy
policy.require_trusted_types_for :script

Read the limitation below before assuming this makes Lexxy work under Trusted
Types. It does not.

Why

#1231 gives Lexxy its own DOMPurify instance, so a page whose host app also uses
DOMPurify now has two. Every instance asks Trusted Types for a policy named
dompurify on its first sanitize, and TT refuses a duplicate name — so one of
them gets none.

That is not a degraded mode. DOMPurify parses through
DOMParser.parseFromString, which is itself a TT sink, so an unsigned instance
throws. Which sanitizer breaks would come down to which one happened to run
first — and it could just as easily be the host's as ours.

So Lexxy asks under its own name and hands the policy to DOMPurify rather than
letting DOMPurify try. Creating a policy the CSP hasn't allowlisted throws, and
that throw is caught: an app that doesn't list lexxy falls back to no policy,
which is exactly where this stood before the series. Browsers without Trusted
Types take the same path. No new public API, no new option.

⚠️ The limitation, found in a browser

The original version of this change (and its PR description) read as though
allowlisting lexxy were enough. It isn't, and the manual browser check that
should have run before #1227 was merged is what caught it.

Chromium, require-trusted-types-for 'script', both with lexxy allowlisted and
without — the editor throws while connecting:

TypeError: Failed to execute 'parseFromString' on 'DOMParser':
This document requires 'TrustedHTML' assignment.
  at parseHtml (src/helpers/html_helper.js:20)
  at #parseHtmlIntoLexicalNodes → #setEditorHtml → #loadInitialValue

That sink is Lexxy's own, not DOMPurify's, so no policy handed to DOMPurify
can cover it. It is not the only one either — the attachment content
insertAdjacentHTML and the innerHTML writes behind the toolbar and dropdowns
are all unwrapped.

None of this is new and none of it is changed by this series: html_helper.js is
untouched by it. Lexxy has never run under enforced Trusted Types. What was wrong
was the claim, not the code.

Worth noting how the claim got lost: the comment this series replaced did carry
the caveat — "the insertAdjacentHTML sink in
nodes/custom_action_text_attachment_node.js receives a plain string and needs
the same attention, independently of this"
— and the rewrite deleted it while
reading as though the problem were solved. It is restored here, in the source
comment and in the docs, with the sinks named.

Making Lexxy usable under Trusted Types is separate work. This PR is a
prerequisite for it, and is worth having on its own regardless: it stops us being
the reason a host app's sanitizer breaks.

Docs

Adds a Content Security Policy section to home/docs/configuration.md,
including a "Lexxy does not yet work under enforced Trusted Types" subsection.
Until now this requirement lived only in a source comment, and this project has
no CHANGELOG — release notes are generated from PR titles.

Verification

  • Unit: against a stub in all three shapes — name allowlisted, name refused, no
    restriction at all.
  • Browser (Chromium, real CSP): policy created when allowlisted; refusal reported
    as trusted-types trusted-types-policy lexxy and caught when not; editor fails
    identically either way, for the reason above.

Part of a series re-filing #1227 at reviewable scope, after #1227 was reverted
from main in 8c64aa4. Merge order: #1228#1229#1230#1231this
#1226. Nothing here is released.

Draft: needs human review and a soak period before merging.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a dedicated lexxy Trusted Types policy to prevent DOMPurify policy-name collisions and documents the required CSP configuration.

Changes:

  • Creates and supplies the lexxy Trusted Types policy.
  • Adds policy-name unit coverage.
  • Documents CSP setup for consumers.

Tip

If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
src/config/dom_purify.js Creates and configures the Trusted Types policy.
test/javascript/unit/helpers/sanitization_helper.test.js Tests dedicated policy creation.
home/docs/configuration.md Documents CSP requirements and Rails configuration.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/config/dom_purify.js Outdated
Comment thread test/javascript/unit/helpers/sanitization_helper.test.js
@jeremy jeremy changed the title Require a "lexxy" Trusted Types policy in your CSP Claim a "lexxy" Trusted Types policy instead of racing the host for "dompurify" Aug 11, 2026
@jeremy

jeremy commented Aug 11, 2026

Copy link
Copy Markdown
Member Author

The browser check changed this PR

Summarising what is already in the body and the last commit, because it is the reason this PR was retitled.

Original title: "Require a lexxy Trusted Types policy in your CSP" — which reads as "do this and Lexxy works under Trusted Types."

It doesn't. Chromium, real require-trusted-types-for 'script', both directions:

policy created editor works
trusted-types dompurify lexxy
trusted-types dompurify (ours refused) ❌ (refusal caught, as designed)

Both fail identically:

TypeError: Failed to execute 'parseFromString' on 'DOMParser':
This document requires 'TrustedHTML' assignment.
  at parseHtml (src/helpers/html_helper.js:20)
  at #parseHtmlIntoLexicalNodes → #setEditorHtml → #loadInitialValue

That sink is Lexxy's own, not DOMPurify's, so no policy handed to DOMPurify covers it — and it is one of about ten unwrapped raw-HTML sinks in src/ (insertAdjacentHTML in the attachment node, the innerHTML writes behind the toolbar, dropdowns, delete button, code highlighting).

Long-standing and untouched by this series: html_helper.js has no diff against main. The code was fine; the claim was wrong.

Retitled to "Claim a lexxy Trusted Types policy instead of racing the host for dompurify", which is what it actually does and is worth doing on its own: it stops us being the reason a host app's sanitizer loses the policy-name race and throws.

Where the claim came from

Worth recording, because it is the same failure mode as the over-scoping this whole campaign is unwinding. Two commits in #1227 (d5d35ed6, 1a2fa26e) documented the limitation accurately, including:

the insertAdjacentHTML sink in nodes/custom_action_text_attachment_node.js receives a plain string and needs the same attention, independently of this

The later commit c46bdd2e rewrote that comment block to describe the new policy and deleted the caveat, leaving text that read as though the problem were solved. Nobody re-read it against a browser. The plan for this campaign then classified those two commits as "comment-only, superseded" — true, but the thing that superseded them was wrong.

The caveat is restored, in the source comment and in a "Lexxy does not yet work under enforced Trusted Types" section in home/docs/configuration.md, with the sinks named so the next person doesn't rediscover them in a browser.

Follow-up for a human

Making Lexxy usable under Trusted Types is real, separate work: wrap every sink in src/ through the policy, and test it under enforcement in CI. Worth an issue. Deliberately not done here — that is exactly the scope creep this campaign exists to undo.


CI: green.

@jeremy

jeremy commented Aug 11, 2026

Copy link
Copy Markdown
Member Author

Handoff note for the whole campaign, including QA results and what still needs a human decision: #1234

@jeremy
jeremy force-pushed the trusted-types-policy branch from a3f4eb0 to 22f5d01 Compare August 11, 2026 16:54
jeremy added 3 commits August 11, 2026 22:37
Action required if — and only if — your app enforces
`require-trusted-types-for 'script'`. Add `lexxy` to your `trusted-types`
directive. Nothing else changes.

Now that Lexxy sanitizes with its own DOMPurify instance, there are two
instances on a page whose host app also uses DOMPurify. Every instance asks
Trusted Types for a policy named `dompurify` on its first sanitize, and TT
refuses a duplicate name, so one of them gets none. That is not a degraded
mode: DOMPurify parses through DOMParser.parseFromString, which is itself a TT
sink, so an unsigned instance throws. Which sanitizer breaks would depend on
which one happened to run first.

So Lexxy asks under its own name and hands the policy to DOMPurify rather than
letting DOMPurify try. Creating a policy the CSP hasn't allowlisted throws, and
that throw is caught: an app that doesn't list `lexxy` falls back to no policy,
which is exactly where this stood before this series. Browsers without Trusted
Types take the same path. There is no new public API and no new option.

Documented in home/docs/configuration.md, with the Rails initializer, because
until now this requirement existed only in a source comment. Release notes here
are generated from PR titles, so the title carries it too.

Verified against a stub in all three shapes: name allowlisted, name refused, no
restriction at all.
…enforced TT

Manual browser check in Chromium under `require-trusted-types-for 'script'`,
which is the step that should have run before #1227 was merged. With `lexxy`
allowlisted and without it, the editor throws while connecting:

  TypeError: Failed to execute 'parseFromString' on 'DOMParser':
  This document requires 'TrustedHTML' assignment.
  at parseHtml (src/helpers/html_helper.js:20)
  at #parseHtmlIntoLexicalNodes → #setEditorHtml → #loadInitialValue

That sink is Lexxy's own, not DOMPurify's, so no policy handed to DOMPurify can
cover it — and it is far from the only one: the attachment content
insertAdjacentHTML and the innerHTML writes behind the toolbar and dropdowns are
all in the same position. None of this is new, and none of it is changed by this
series; html_helper.js is untouched by it.

What the policy does buy stands, and is worth having on its own: our instance no
longer claims the `dompurify` name, so it cannot be the reason the *host app's*
sanitizer loses the race and throws.

The claim is what was wrong. The comment this series replaced carried the
caveat — "the insertAdjacentHTML sink ... needs the same attention,
independently of this" — and the rewrite dropped it while reading as though the
problem were solved. Restored here, in the source comment and in the docs, with
the sinks named so the next person doesn't have to rediscover them in a browser.

Verified in the guarded-refusal direction too: with `lexxy` absent from the
directive the browser reports `trusted-types trusted-types-policy lexxy`, the
throw is caught, and nothing else changes.
Two review catches.

STYLE.md:60 asks for a plain conditional over a ternary, and the conditional
spread was one. Building the config and assigning the policy after reads better
anyway, and it makes the reason for the shape explicit: the key has to be
*absent* rather than present-and-undefined, because DOMPurify reads
cfg.TRUSTED_TYPES_POLICY and validates it.

The tests only covered the happy path, while the PR claimed verification "in all
three shapes: name allowlisted, name refused, no restriction at all". Two of the
three were only ever checked by hand in a browser. Both are now unit tests, and
they assert the absent key rather than merely that nothing threw — mutation
checked by removing the catch, which fails the refusal case.
@jeremy
jeremy force-pushed the trusted-types-policy branch from 22f5d01 to f0b3488 Compare August 12, 2026 05:39
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.

2 participants