Fix XSS via unsanitized action-text-attachment content attribute#903
Conversation
There was a problem hiding this comment.
Pull request overview
This PR addresses an XSS vector where CustomActionTextAttachmentNode renders HTML from the content attribute into the live DOM without sanitization, and adds Playwright regression coverage around paste-driven injection attempts.
Changes:
- Sanitize custom attachment
innerHtmlbefore inserting it into the DOM. - Add a dedicated Playwright test suite covering multiple XSS payload variants and a “legitimate mention” case.
- Introduce a new
sanitizeAttachmentContent()helper using DOMPurify without the editor’s restrictive allowlist.
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 5 comments.
| File | Description |
|---|---|
src/nodes/custom_action_text_attachment_node.js |
Sanitizes attachment innerHtml before inserting into the DOM. |
src/helpers/sanitization_helper.js |
Adds sanitizeAttachmentContent() helper (DOMPurify default allowlist path). |
test/browser/tests/paste/xss_sanitization.test.js |
Adds Playwright regression tests for XSS attempts via content attribute. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| await editor.paste("", { html: scriptPayload }) | ||
| await editor.flush() | ||
| await page.waitForTimeout(1000) | ||
|
|
||
| expect(dialogTriggered).toBe(false) | ||
|
|
There was a problem hiding this comment.
These fixed waitForTimeout(1000) delays will add multiple seconds to the Playwright suite and can be a source of flakiness. Prefer waiting on a specific condition (e.g., page.waitForEvent('dialog', { timeout: ... }) and asserting it times out, or using locator-based expectations) with a smaller timeout budget.
| // The XSS alert should not have fired | ||
| expect(dialogTriggered).toBe(false) | ||
|
|
||
| // The malicious img with onerror should not be present in the DOM | ||
| await assertEditorContent(editor, async (content) => { | ||
| await expect(content.locator("img[onerror]")).toHaveCount(0) | ||
| }) | ||
| }) |
There was a problem hiding this comment.
The regression tests validate the live DOM is safe, but they don’t currently assert that the editor’s serialized output (e.g. editor.value() / exported HTML) has had the XSS payload removed from the attachment content attribute. Adding an assertion that the exported HTML/JSON no longer contains onerror, <script>, or <meta http-equiv=refresh> would help prevent a stored-payload regression.
| createDOM() { | ||
| const figure = createElement(this.tagName, { "content-type": this.contentType, "data-lexxy-decorator": true }) | ||
|
|
||
| figure.insertAdjacentHTML("beforeend", this.innerHtml) | ||
| figure.insertAdjacentHTML("beforeend", sanitizeAttachmentContent(this.innerHtml)) | ||
|
|
There was a problem hiding this comment.
Sanitizing only at render-time means the node still retains the original (potentially malicious) innerHtml, and exportDOM() / exportJSON() will persist it back into the content attribute unchanged. That leaves a stored payload in saved HTML/JSON that could be re-used by other consumers (or by older versions) and defeats the goal of removing the XSS payload from the document. Consider sanitizing innerHtml when constructing/importing the node (e.g., in importDOM conversion and/or the constructor) so both rendering and exports use the sanitized value (or sanitize on export).
| // Uses DOMPurify defaults to strip XSS vectors (scripts, event handlers) | ||
| // while preserving the richer tag set that server-rendered attachment | ||
| // content legitimately uses (e.g. <span>, <div>, <img>). |
There was a problem hiding this comment.
sanitizeAttachmentContent() is documented as using “DOMPurify defaults”, but this module imports buildConfig from src/config/dom_purify.js, which globally registers DOMPurify hooks (style filtering, class stripping on strong/em). Those hooks will still run here, so the behavior is not pure defaults. Either adjust the comment to reflect that only the tag/attr allowlist is default, or create a separate DOMPurify instance for attachment content if the intent is truly unmodified defaults.
| // Uses DOMPurify defaults to strip XSS vectors (scripts, event handlers) | |
| // while preserving the richer tag set that server-rendered attachment | |
| // content legitimately uses (e.g. <span>, <div>, <img>). | |
| // Uses the shared DOMPurify instance configured in ../config/dom_purify | |
| // (including its global hooks like style filtering) but relies on the | |
| // default tag/attribute allowlist (no additional per-call config) to | |
| // strip XSS vectors while preserving the richer tag set that server- | |
| // rendered attachment content legitimately uses (e.g. <span>, <div>, <img>). |
| await editor.paste("", { html: xssPayload }) | ||
| await editor.flush() | ||
| await page.waitForTimeout(1000) | ||
|
|
||
| // The XSS alert should not have fired | ||
| expect(dialogTriggered).toBe(false) |
There was a problem hiding this comment.
These fixed waitForTimeout(1000) delays will add multiple seconds to the Playwright suite and can be a source of flakiness. Prefer waiting on a specific condition (e.g., page.waitForEvent('dialog', { timeout: ... }) and asserting it times out, or using locator-based expectations) with a smaller timeout budget.
CustomActionTextAttachmentNode.createDOM() inserted the innerHtml extracted from the content attribute directly into the DOM via insertAdjacentHTML without sanitization. Because DOMPurify treats content as a harmless string attribute during paste, malicious HTML (e.g. <img onerror=alert(...)>, <script>, <meta http-equiv=refresh>) survived the initial paste sanitization and was injected into the live DOM when the node rendered. Sanitize innerHtml with DOMPurify.sanitize() (using its defaults to strip XSS vectors while preserving the richer tag set that server-rendered attachment content legitimately uses) before inserting it into the DOM in createDOM().
8d9664a to
a3985b3
Compare
Summary
CustomActionTextAttachmentNode.createDOM()insertedinnerHtmlextracted from thecontentattribute directly into the DOM viainsertAdjacentHTMLwithout sanitization. Because DOMPurify treatscontentas a harmless string attribute during paste, malicious HTML (e.g.<img onerror=alert(...)>,<script>,<meta http-equiv=refresh>) survived the initial paste sanitization and was injected into the live DOM when the node rendered.innerHtmlwithDOMPurify.sanitize()before inserting it into the DOM. Uses DOMPurify's defaults (not the editor's restrictive tag allowlist) so legitimate attachment content (mentions with<span>, cards, etc.) is preserved while XSS vectors are stripped.onerror,<script>injection,<meta http-equiv=refresh>injection, and verifying legitimate mention content still renders correctly.Fizzy card #5072 | Fizzy card #5073