Apply mXSS-safe SAFE_FOR_XML on untrusted re-inflation paths - #1334
Apply mXSS-safe SAFE_FOR_XML on untrusted re-inflation paths#1334jeremy wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Hardens stored HTML re-inflation with DOMPurify’s XML-safe mode while attempting to preserve serialized attachment comments.
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.
Changes:
- Enables
SAFE_FOR_XMLfor editor loading and composition reparsing. - Adds preservation logic for
data-trix-*attributes. - Adds sanitizer, serialization, and attachment regression tests.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
src/trix/models/html_sanitizer.js |
Preserves serialized Trix attributes under XML-safe sanitization. |
src/trix/models/editor.js |
Enables XML-safe parsing when loading HTML. |
src/trix/models/composition.js |
Enables XML-safe parsing during DOM reparsing. |
src/test/unit/serialization_test.js |
Tests safe parsing and serialization round-trips. |
src/test/unit/html_sanitizer_test.js |
Tests XML-unsafe attribute handling. |
src/test/unit/attachment_test.js |
Tests preservation of comment-bearing attachments. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 51d9b84c8c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/trix/models/html_sanitizer.js:34
- This bypasses the XML-safety check for the entire
data-trix-attachmentJSON value, but attachmentcontentis not terminal data:html_parser.js:198parses it into an attachment andattachment_view.js:38later reparses it as HTML with the repository defaultSAFE_FOR_XML: false(config/dompurify.js:3). An attacker can therefore place an mXSS sequence in serialized attachment content, have this hook preserve it past the protected outer parse, and reach a second unprotected parse. ApplySAFE_FOR_XML: truewhen rendering attachment content (and test a nested mXSS payload through the actual editor render), or narrowly preserve only data that cannot be reparsed as markup.
data.attrValue = data.attrValue.replace(XML_UNSAFE_ATTRIBUTE_VALUE, "")
data.forceKeepAttr = true
src/test/unit/serialization_test.js:20
- This helper manually supplies the new option instead of exercising either changed production entry point. Because
Editor.loadHTMLandComposition.replaceHTMLonly add this wiring, these tests still pass if either production change is removed. Add system coverage that sends the payload througheditor.loadHTMLand through a DOM mutation that invokesreplaceHTML, then verifies the rendered/serialized result.
const reinflate = (html) => {
const document = HTMLParser.parse(html, { purifyOptions: { SAFE_FOR_XML: true } }).getDocument()
return serializeToContentType(document, "text/html")
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/trix/models/html_sanitizer.js:34
- This bypass leaves nested attachment HTML outside the new mXSS protection.
data-trix-attachmentis JSON-decoded byhtml_parser.js:41-44, then itscontentis re-parsed byAttachmentViewthroughHTMLSanitizer.setHTMLwithoutSAFE_FOR_XML(attachment_view.js:37-38), while the default remains false. An mXSS payload insidecontenttherefore skips the outer guard here and reaches another unsafe sanitize/serialize/reparse cycle; the new system test covers only an ordinaryonerrorpayload. ApplySAFE_FOR_XMLto that second parse and add a nested mXSS regression test before exempting this attribute.
data.attrValue = data.attrValue.replace(XML_UNSAFE_ATTRIBUTE_VALUE, "")
data.forceKeepAttr = true
eed374b to
872fc84
Compare
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Stored Trix HTML is re-inflated back into the editor on load (editor.loadHTML) and on reparse (composition.replaceHTML). Both parsed under DOMPurify's default config, which leaves SAFE_FOR_XML off and so does not defend the storage round-trip against mutation-XSS. insertHTML already opts into SAFE_FOR_XML: true per call; extend the same idiom to the two remaining re-inflation entry points. This is deliberately scoped per call rather than a global flip of config.dompurify. A global SAFE_FOR_XML: true reintroduces the #1213 regression: attachment content serialized into the data-trix-attachment attribute can carry Rails view-annotation comments (<!-- BEGIN ... -->), and DOMPurify's SAFE_FOR_XML attribute-value guard drops any attribute whose value contains a comment terminator, silently deleting the attachment on the round-trip. Preserve serialized data-trix-* attributes under SAFE_FOR_XML with an uponSanitizeAttribute hook: neutralize only the copy DOMPurify inspects for its XML-safety guard, then forceKeepAttr keeps the original value verbatim. These are data attributes, always entity-escaped on serialization and never re-parsed as markup, so keeping them is mXSS-safe. The neutralization is scoped to data-trix-* only; XML-unsafe values on ordinary attributes are still stripped. Regression tests cover both invariants: an mXSS payload is neutralized after the parse/serialize round-trip, and an HTML comment inside an attachment survives it.
The re-inflation round-trip test asserted that no <img> survived the mutation-XSS payload. That over-specifies a browser-parser detail rather than a security property: Firefox parses the noscript payload such that a handler-stripped <img src="x"> remains and Trix promotes it to a benign image attachment, while Chromium collapses the payload entirely. The onerror handler is neutralized on every browser. Assert the actual invariant instead — no onerror, no event-handler attribute, and no <script> survive — and re-inflate the sanitized output a second time to prove it is a stable fixed point that cannot mutate back into an executable form. Also check in the regenerated Action Text vendored trix.js, which the SAFE_FOR_XML source changes require rebuilding.
Add a system test group that drives the real production entry point (editor.loadHTML) rather than the sanitizer in isolation: it re-parses stored HTML under SAFE_FOR_XML and renders it into the live editor, including attachment content re-parsed by AttachmentView. It asserts the security invariant — no live event-handler attribute, no <script>, and no execution — which is a browser-independent DOMPurify guarantee and so holds across the Sauce matrix.
872fc84 to
4292ffa
Compare
What
Stored Trix HTML is re-inflated back into the editor on load (
editor.loadHTML) and on reparse (composition.replaceHTML). Both parsed through DOMPurify with the default config, which leavesSAFE_FOR_XMLoff — so the untrusted storage round-trip is not defended against mutation-XSS.composition.insertHTMLalready opts into{ purifyOptions: { SAFE_FOR_XML: true } }per call; this extends the same idiom to the two remaining re-inflation entry points.Why this is scoped per-call, not a global flip
A global
SAFE_FOR_XML: trueinconfig/dompurify.jswould reintroduce the regression fixed in #1213. Attachment content is serialized into thedata-trix-attachmentattribute, and withconfig.action_view.annotate_rendered_view_with_filenameson, that content carries Rails view-annotation comments like<!-- BEGIN app/views/users/_user.html.erb -->. DOMPurify'sSAFE_FOR_XMLattribute-value guard drops any attribute whose value contains a comment terminator (-->,--!>,]>, or a raw</style-style close), so the wholedata-trix-attachmentattribute is stripped and the attachment silently disappears on the round-trip. That guard runs before DOMPurify consultsforceKeepAttr, so the existing data-trix keep-hook alone does not save it.The preservation hook
The
uponSanitizeAttributehook inhtml_sanitizer.jsnow, fordata-trix-*attributes, neutralizes only the copy DOMPurify inspects for its XML-safety guard and then setsforceKeepAttr, which keeps the original value verbatim (the neutralized copy is never written to the DOM). These are data attributes — always entity-escaped on serialization, never re-parsed as markup — so retaining them is mXSS-safe. The neutralization is scoped todata-trix-*; XML-unsafe values on ordinary attributes are still stripped bySAFE_FOR_XML.Changes
models/editor.js—loadHTMLparses withSAFE_FOR_XML: truemodels/composition.js—replaceHTMLparses withSAFE_FOR_XML: truemodels/html_sanitizer.js— preservation hook for serializeddata-trix-*attribute valuesTests
html_sanitizer_test.js— data-trix-* comment markers preserved underSAFE_FOR_XML; XML-unsafe values on non-data-trix attributes still stripped (scoping proof)serialization_test.js— round-trip: mXSS payload neutralized; attachment comment survivesattachment_test.js— re-inflation keeps a comment-bearing attachment and leaves its content byte-identicalyarn test(web-test-runner / Playwright Chromium): 506 tests, 477 passed, 0 failed, 29 pre-existing skips.Follow-up (deliberately held)
npm publish + the
bc3consumer bump are gated follow-ups and are not part of this PR.