Skip to content

Apply mXSS-safe SAFE_FOR_XML on attachment content re-inflation - #1226

Open
jeremy wants to merge 3 commits into
mainfrom
security/xss-campaign-safe-xml
Open

Apply mXSS-safe SAFE_FOR_XML on attachment content re-inflation#1226
jeremy wants to merge 3 commits into
mainfrom
security/xss-campaign-safe-xml

Conversation

@jeremy

@jeremy jeremy commented Aug 9, 2026

Copy link
Copy Markdown
Member

What

Stored attachment content is re-inflated back into the editor by CustomActionTextAttachmentNode#createDOM, which sanitizes the serialized content HTML and injects it into the live editor DOM. That sanitize() ran under the base DOMPurify config, which leaves SAFE_FOR_XML off — so the untrusted storage round-trip was not defended against mutation-XSS. This opts into DOMPurify's mXSS-safe mode per call on that re-inflation path.

Why this is scoped per-call, not a global flip

The base config sets SAFE_FOR_XML: false deliberately (see its comment) so serialized-HTML attributes survive. A global SAFE_FOR_XML: true would reintroduce exactly that regression: attachment content is serialized into the content attribute of an <action-text-attachment>, and DOMPurify's SAFE_FOR_XML attribute-value guard drops any attribute whose value contains an XML-unsafe sequence — a comment terminator (-->, --!>, ]>, e.g. from Rails view annotations <!-- BEGIN app/views/... -->) or a raw </style-style close. That strips content and the attachment silently disappears on the round-trip. That guard runs before DOMPurify consults forceKeepAttr, so a keep-hook alone is not enough.

The preservation hook

A new uponSanitizeAttribute hook (preserveSerializedContentHook) neutralizes only the copy DOMPurify inspects for its XML-safety guard, then sets forceKeepAttr, which keeps the original content value verbatim (the neutralized copy is never written to the DOM). The content attribute is inert — always entity-escaped on serialization and only ever re-parsed and re-sanitized by the attachment node's own renderer — so preserving it is mXSS-safe. XML-unsafe values on ordinary attributes are still stripped.

DOMPurify ignores a per-call config while a persistent config is set (setConfig), so sanitize(html, { safeForXml: true }) briefly swaps in the SAFE_FOR_XML config and restores the base config afterward.

Changes

  • config/dom_purify.js — preservation hook for the serialized content attribute; clarify why the base config keeps SAFE_FOR_XML: false
  • helpers/sanitization_helper.jssanitize(html, { safeForXml }) per-call opt-in via config swap
  • nodes/custom_action_text_attachment_node.jscreateDOM sanitizes re-inflated content with safeForXml: true

Tests

  • helpers/sanitization_helper.test.js — content attribute preserved under SAFE_FOR_XML (and in the base config); XML-unsafe values on non-content attributes still stripped (scoping proof, non-vacuous: title is otherwise allowed)
  • editor/attachments/content_reinflation_sanitization.test.js — full editor round-trip: XSS payload in attachment content neutralized; comment-bearing content survives

yarn test (vitest / jsdom): 132 tests, all passing.

Follow-up (deliberately held)

The dual publish (npm @37signals/lexxy + Ruby gem) and the bc3 / fizzy consumer bumps are gated follow-ups and are not part of this PR.

Stored attachment content is re-inflated back into the editor by the
custom attachment node's createDOM, which sanitizes the serialized
`content` HTML and injects it into the live editor DOM. That sanitize ran
under the base DOMPurify config, which leaves SAFE_FOR_XML off, so the
untrusted storage round-trip was not defended against mutation-XSS.

Opt into DOMPurify's mXSS-safe mode per call on this re-inflation path
rather than flipping the base config. A global SAFE_FOR_XML: true would
reintroduce the very regression the base config's comment guards against:
attachment content is serialized into the `content` attribute of an
<action-text-attachment>, and DOMPurify's SAFE_FOR_XML attribute-value
guard drops any attribute whose value contains an XML-unsafe sequence
(a comment terminator like `-->`, from Rails view annotations, or a raw
tag close). That would strip `content` and silently lose the attachment.

Preserve the serialized `content` attribute 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. The content attribute is inert — always entity-escaped on
serialization and only ever re-parsed and re-sanitized by the attachment
node's own renderer — so keeping it is mXSS-safe. XML-unsafe values on
ordinary attributes are still stripped.

DOMPurify ignores a per-call config while a persistent config is set, so
sanitize() briefly swaps in the SAFE_FOR_XML config and restores the base
config afterward.

Regression tests cover both invariants: the serialized content attribute
survives sanitization under SAFE_FOR_XML, and attachment content is
sanitized on the re-inflation round-trip.
Copilot AI balanced review requested due to automatic review settings August 9, 2026 00:47

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 per-call mXSS-safe sanitization when restoring stored attachment HTML.

Changes:

  • Adds scoped SAFE_FOR_XML configuration swapping.
  • Preserves serialized attachment content attributes.
  • Adds sanitization and attachment re-inflation tests.

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 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/config/dom_purify.js Adds serialized-content preservation hook.
src/helpers/sanitization_helper.js Supports per-call XML-safe sanitization.
src/nodes/custom_action_text_attachment_node.js Enables XML-safe attachment re-inflation.
test/javascript/unit/helpers/sanitization_helper.test.js Tests sanitizer configuration behavior.
test/javascript/unit/editor/attachments/content_reinflation_sanitization.test.js Tests attachment content restoration.

💡 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 on lines +18 to +20
test("neutralizes an XSS payload smuggled through attachment content", async () => {
const payload = "&quot;&lt;img src=x onerror=alert(document.domain)&gt;&quot;"
editorElement = await createTestEditor({ value: attachment(payload) })

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fair point — that payload is a plain onerror the base config already strips, so it doesn't isolate the SAFE_FOR_XML contribution. I dug into whether a non-vacuous version is achievable and what I found is worth recording:

  • SAFE_FOR_XML: true does produce a real, cross-browser differential. For <noscript><p title="</noscript><img src=x onerror=alert(1)>">, DOMPurify keeps <p title="…onerror…"> under false and drops the attribute entirely (<p></p>) under true — identical in Chromium, Firefox, and WebKit. So the option is not a no-op.
  • But that differential is an inert attribute (the handler is trapped in a quoted title; it doesn't execute), so a "did it execute?" assertion can't distinguish the two configs with these payloads.
  • Critically, the differential does not reproduce under jsdom (this suite's env). I verified both configs yield byte-identical output there, because the mutation depends on the real browser parser. So a Vitest unit test structurally cannot demonstrate the SAFE_FOR_XML contribution — strengthening the jsdom payload won't help.

A genuinely non-vacuous regression test would need the browser Playwright suite and a payload whose mutation actually executes under the base config (not just leaves an inert attribute) — which the known inert-attribute cases here don't give. I've flagged that as a follow-up rather than shipping a jsdom test that looks like it proves the guarantee but can't. The Vitest tests remain as behavioral coverage (the safe-XML path runs and the serialized content attribute is preserved), and the scoping fix on this file is covered by its new negative test.

Comment on lines +29 to +31
test("preserves legitimate comment-bearing attachment content after round-trip", async () => {
const content = "&lt;!-- BEGIN app/views/users/_user.html.erb --&gt;&lt;span&gt;Chris&lt;/span&gt;&lt;!-- END app/views/users/_user.html.erb --&gt;"
editorElement = await createTestEditor({ value: attachment(content) })

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Flagged for human review. Acknowledging the AGENTS.md convention that serialization changes get a Capybara editor→save→render→re-edit test. Two things shape whether it belongs in this PR:

  • This change is client-side only — it adjusts DOMPurify (client) sanitization on the attachment re-inflation path. It does not touch the Loofah (server) or highlightCode() (rendered view) stages. The round-trip risk this PR itself introduces is the client-side SAFE_FOR_XML attribute-drop, and the Vitest suite covers exactly that: it asserts the comment-bearing serialized content attribute survives sanitization (both base and safe-XML configs). A Loofah/rendered-view drop would be a pre-existing behavior unchanged by this diff.
  • A full Capybara round-trip is still a reasonable addition, but it needs the dummy-app system-test harness and would mainly exercise stages this PR doesn't change.

Flagging for a maintainer to decide whether to add the Capybara test here or as a follow-up, rather than adding a heavier system test that overlaps the unchanged server stages.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 894f866c38

ℹ️ 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".

Comment on lines +18 to +20
test("neutralizes an XSS payload smuggled through attachment content", async () => {
const payload = "&quot;&lt;img src=x onerror=alert(document.domain)&gt;&quot;"
editorElement = await createTestEditor({ value: attachment(payload) })

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Add a failing browser-level mXSS reproduction

This test runs under jsdom and uses a basic onerror payload that the previous sanitizer already removed, so it would pass before this commit and does not exercise the browser parser mutations that SAFE_FOR_XML is intended to prevent. Add a real mXSS payload to the Playwright suite and confirm that it fails against the parent revision before relying on it as the security regression test.

AGENTS.md reference: AGENTS.md:L63-L66

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fair point — that payload is a plain onerror the base config already strips, so it doesn't isolate the SAFE_FOR_XML contribution. I dug into whether a non-vacuous version is achievable and what I found is worth recording:

  • SAFE_FOR_XML: true does produce a real, cross-browser differential. For <noscript><p title="</noscript><img src=x onerror=alert(1)>">, DOMPurify keeps <p title="…onerror…"> under false and drops the attribute entirely (<p></p>) under true — identical in Chromium, Firefox, and WebKit. So the option is not a no-op.
  • But that differential is an inert attribute (the handler is trapped in a quoted title; it doesn't execute), so a "did it execute?" assertion can't distinguish the two configs with these payloads.
  • Critically, the differential does not reproduce under jsdom (this suite's env). I verified both configs yield byte-identical output there, because the mutation depends on the real browser parser. So a Vitest unit test structurally cannot demonstrate the SAFE_FOR_XML contribution — strengthening the jsdom payload won't help.

A genuinely non-vacuous regression test would need the browser Playwright suite and a payload whose mutation actually executes under the base config (not just leaves an inert attribute) — which the known inert-attribute cases here don't give. I've flagged that as a follow-up rather than shipping a jsdom test that looks like it proves the guarantee but can't. The Vitest tests remain as behavioral coverage (the safe-XML path runs and the serialized content attribute is preserved), and the scoping fix on this file is covered by its new negative test.

Comment on lines +29 to +31
test("preserves legitimate comment-bearing attachment content after round-trip", async () => {
const content = "&lt;!-- BEGIN app/views/users/_user.html.erb --&gt;&lt;span&gt;Chris&lt;/span&gt;&lt;!-- END app/views/users/_user.html.erb --&gt;"
editorElement = await createTestEditor({ value: attachment(content) })

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Exercise the required Action Text round trip

Despite its name, this test only loads a value into a client-side editor and reads the client export; it never saves a dummy-app post or passes the attachment through Loofah, rendered Action Text, and re-editing. Because this change alters sanitization and preservation of the serialized content attribute, a server-stage regression could still silently remove the attachment, so add the required Capybara save → render → re-edit test.

AGENTS.md reference: AGENTS.md:L53-L55

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Flagged for human review. Acknowledging the AGENTS.md convention that serialization changes get a Capybara editor→save→render→re-edit test. Two things shape whether it belongs in this PR:

  • This change is client-side only — it adjusts DOMPurify (client) sanitization on the attachment re-inflation path. It does not touch the Loofah (server) or highlightCode() (rendered view) stages. The round-trip risk this PR itself introduces is the client-side SAFE_FOR_XML attribute-drop, and the Vitest suite covers exactly that: it asserts the comment-bearing serialized content attribute survives sanitization (both base and safe-XML configs). A Loofah/rendered-view drop would be a pre-existing behavior unchanged by this diff.
  • A full Capybara round-trip is still a reasonable addition, but it needs the dummy-app system-test harness and would mainly exercise stages this PR doesn't change.

Flagging for a maintainer to decide whether to add the Capybara test here or as a follow-up, rather than adding a heavier system test that overlaps the unchanged server stages.

preserveSerializedContentHook set forceKeepAttr on any element bearing a
content attribute. forceKeepAttr bypasses DOMPurify's tag-scoped attribute
allowlist, so this retained an attacker-controlled content attribute on
elements (e.g. <span content=...>) whose allowlist does not permit it —
content is a registered allowed attribute only on the attachment tag.

Restrict the preservation to the attachment element, so the forceKeepAttr
bypass never applies elsewhere while the serialized attachment content is
still preserved under SAFE_FOR_XML. Add a negative test asserting content
on a non-attachment element is dropped.
Copilot AI review requested due to automatic review settings August 9, 2026 01:56

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

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

test/javascript/unit/editor/attachments/content_reinflation_sanitization.test.js:20

  • This is a regular img[onerror] payload, which the previous DOMPurify call already removes with SAFE_FOR_XML: false; therefore the test passes before this fix and does not regress the reported mutation-XSS path. Add a real mXSS payload that fails against main and exercise it in Playwright, since browser parsing/mutation behavior is not represented reliably by jsdom. This is required by the bug-regression and browser-test guidance in AGENTS.md:64-66 and STYLE.md:313-319.
  test("neutralizes an XSS payload smuggled through attachment content", async () => {
    const payload = "&quot;&lt;img src=x onerror=alert(document.domain)&gt;&quot;"
    editorElement = await createTestEditor({ value: attachment(payload) })

test/javascript/unit/editor/attachments/content_reinflation_sanitization.test.js:31

  • This only round-trips through the in-memory editor value; it never crosses Action Text/Loofah persistence, rendered output, and re-editing. Because this PR changes sanitization and serialized attachment content, AGENTS.md:53-55 explicitly requires a Capybara system test covering editor → save → render → re-edit. Add that test so comment-bearing content is proven not to disappear in the actual storage path.
  test("preserves legitimate comment-bearing attachment content after round-trip", async () => {
    const content = "&lt;!-- BEGIN app/views/users/_user.html.erb --&gt;&lt;span&gt;Chris&lt;/span&gt;&lt;!-- END app/views/users/_user.html.erb --&gt;"
    editorElement = await createTestEditor({ value: attachment(content) })

Comment thread src/config/dom_purify.js
Checking the attachment tag name alone still force-kept content when a
caller registered the attachment tag but omitted content from its
attributes (e.g. the attachments-disabled setup, where the tag is
importable but its attributes are not registered). forceKeepAttr then
bypassed the caller's explicit omission.

Track, in buildConfig, the set of tags the current config actually allows
content on, and gate the preservation hook on membership. The bypass now
never leaks past what the config permits: an attachment tag without content
in its allowlist has the attribute stripped like any other. Add a test for
that configuration.
Copilot AI review requested due to automatic review settings August 9, 2026 02:12

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

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment thread src/config/dom_purify.js
Comment on lines +54 to +55
const tag = currentNode?.nodeName?.toLowerCase()
const isAllowedContent = hookEvent.attrName === "content" && contentAllowedTags.has(tag)
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