diff --git a/src/config/dom_purify.js b/src/config/dom_purify.js index 23d5f5f87..203d1f3f3 100644 --- a/src/config/dom_purify.js +++ b/src/config/dom_purify.js @@ -1,7 +1,24 @@ -import DOMPurify from "dompurify" +import createDOMPurify from "dompurify" import { getCSSFromStyleObject, getStyleObjectFromCSS } from "@lexical/selection" import Lexxy from "./lexxy" +// Lexxy's own DOMPurify instance, deliberately not the shared default export. +// +// dompurify's default export is a singleton, and both its config and its hooks +// are global to every consumer in the bundle. That makes configuring it from an +// editor's connectedCallback actively dangerous for the host app: DOMPurify +// treats a persistent config as final, so once setConfig() has run, every +// later `sanitize(html, config)` anywhere in the app silently ignores its own +// config argument. An app sanitizing untrusted HTML with, say, +// `{ ALLOW_DATA_ATTR: false }` would keep passing that option and stop getting +// it the moment a Lexxy editor connected — with no error and no visible change +// at the call site. +// +// Calling the default export with a window returns a fresh, independent +// instance. This one carries the hooks and config below; nothing we do here can +// reach the app's instance, and nothing it does can reach ours. +const DOMPurify = createDOMPurify(window) + // alt is inert on every element it can appear on, so it sits in the blanket // list. srcset is deliberately absent — it carries URLs, so it belongs to a // consumer that declares it. diff --git a/src/elements/editor.js b/src/elements/editor.js index 6739dd4d0..710ba0c49 100644 --- a/src/elements/editor.js +++ b/src/elements/editor.js @@ -361,7 +361,7 @@ export class LexicalEditorElement extends HTMLElement { #readSanitizedEditorValue() { return this.editor?.read(() => { - return sanitize($generateHtmlFromNodes(this.editor, null)) + return sanitize($generateHtmlFromNodes(this.editor, null), this.editor) }) ?? null } @@ -755,7 +755,7 @@ export class LexicalEditorElement extends HTMLElement { } #configureSanitizer(editor) { - setSanitizerConfig(this.#getAllowedElements(editor)) + setSanitizerConfig(editor, this.#getAllowedElements(editor)) } #getAllowedElements(editor) { diff --git a/src/helpers/sanitization_helper.js b/src/helpers/sanitization_helper.js index a745c680a..55ee7ffec 100644 --- a/src/helpers/sanitization_helper.js +++ b/src/helpers/sanitization_helper.js @@ -1,10 +1,37 @@ import { DOMPurify, buildConfig } from "../config/dom_purify" -export function setSanitizerConfig(allowedTags) { - DOMPurify.clearConfig() - DOMPurify.setConfig(buildConfig(allowedTags)) +// Sanitizer config is per editor, and passed to each sanitize() call. +// +// Neither of those is incidental. This used to call DOMPurify.setConfig() on the +// shared singleton, which had two distinct consequences: +// +// 1. A persistent config is final — DOMPurify ignores the per-call config once +// one is set — so it silently disarmed the sanitizing of any host app that +// also imports dompurify. See config/dom_purify for why we now own our +// instance; keeping the config per-call means there is no global sanitizer +// state left even on that instance. +// +// 2. One config for the whole module meant the last editor to connect decided +// how every other editor on the page sanitized. That is not cosmetic: an +// editor's `value` is sanitized on read, so a rich editor sharing a page with +// a plain one would silently drop its own headings, lists and links from the +// value it submits. Keying on the editor is what fixes that. +// +// Registered against the Lexical editor, which is the identity both call sites +// have to hand: the element has it as `this.editor`, and nodes receive it as the +// second argument to createDOM(). +const configs = new WeakMap() + +// Only reached if sanitize() is called for an editor that never registered one. +// Falling back to the most recent config keeps the old behaviour rather than +// silently widening the allowlist to DOMPurify's permissive defaults. +let fallbackConfig = {} + +export function setSanitizerConfig(editor, allowedTags) { + fallbackConfig = buildConfig(allowedTags) + configs.set(editor, fallbackConfig) } -export function sanitize(html) { - return DOMPurify.sanitize(html) +export function sanitize(html, editor) { + return DOMPurify.sanitize(html, configs.get(editor) ?? fallbackConfig) } diff --git a/src/nodes/custom_action_text_attachment_node.js b/src/nodes/custom_action_text_attachment_node.js index 6500ff656..f9a969903 100644 --- a/src/nodes/custom_action_text_attachment_node.js +++ b/src/nodes/custom_action_text_attachment_node.js @@ -72,11 +72,13 @@ export class CustomActionTextAttachmentNode extends DecoratorNode { this.plainText = plainText ?? extractPlainTextFromHtml(innerHtml) } - createDOM() { + createDOM(_config, editor) { const figure = createElement(this.tagName, { "content-type": this.contentType, "data-lexxy-decorator": true, draggable: true }) figure.dataset.lexicalNodeKey = this.__key - figure.insertAdjacentHTML("beforeend", sanitize(this.innerHtml)) + // The editor is passed through so this content is sanitized with its own + // allowlist rather than whichever editor connected most recently. + figure.insertAdjacentHTML("beforeend", sanitize(this.innerHtml, editor)) const deleteButton = createElement("lexxy-node-delete-button") figure.appendChild(deleteButton) diff --git a/test/browser/fixtures/sanitizer-isolation.html b/test/browser/fixtures/sanitizer-isolation.html new file mode 100644 index 000000000..049848e3e --- /dev/null +++ b/test/browser/fixtures/sanitizer-isolation.html @@ -0,0 +1,26 @@ + + +
+ + +body
" + + test("a rich editor keeps its formatting after a plain editor connects", async ({ page }) => { + const rich = new EditorHandle(page, "#rich") + const plain = new EditorHandle(page, "#plain") + + await page.goto("/sanitizer-isolation.html") + await rich.waitForConnected() + await plain.waitForConnected() + + await rich.setValue(RICH) + + // Editing clears the cached value, so the next read re-sanitizes. That read + // is what used to pick up the plain editor's allowlist. + await rich.click() + await rich.send("!") + + const value = await rich.value() + expect(value).toContain("body
" + +let rich, plain + +afterEach(async () => { + await destroyTestEditor(rich) + await destroyTestEditor(plain) + rich = plain = undefined +}) + +async function typeInto(editorElement) { + editorElement.editor.update(() => { + $getRoot().getLastDescendant()?.select().insertText("!") + }, { discrete: true }) + await tick() +} + +test("a rich editor keeps its formatting after a plain editor connects", async () => { + Lexxy.configure({ plain: { richText: false } }) + + rich = await createTestEditor({ value: RICH }) + plain = await createTestEditor({ attributes: { preset: "plain" } }) + await tick() + + // Editing clears the cached value, so the next read re-sanitizes. That read is + // what used to pick up the plain editor's allowlist. + await typeInto(rich) + + expect(rich.value).toContain("${attachment}
`) + + // blockquote is in the rich editor's allowlist but not the plain one's. + expect(rich.querySelector("action-text-attachment").innerHTML).toContain("") +}) diff --git a/test/javascript/unit/helpers/sanitization_helper.test.js b/test/javascript/unit/helpers/sanitization_helper.test.js new file mode 100644 index 000000000..4650eb726 --- /dev/null +++ b/test/javascript/unit/helpers/sanitization_helper.test.js @@ -0,0 +1,99 @@ +import { beforeEach, expect, test } from "vitest" +import DOMPurify from "dompurify" +import { sanitize, setSanitizerConfig } from "src/helpers/sanitization_helper" + +// Lexxy must not configure the DOMPurify singleton the host app also imports. +// +// DOMPurify treats a persistent config as final: after setConfig(), every later +// sanitize(html, config) ignores its config argument. Lexxy configures its +// sanitizer when an editor connects, so if that ran against the shared instance +// it would silently disarm the app's own sanitizing — options the app is still +// passing, and still relying on, would stop applying with no error anywhere. +// +// These assert isolation in both directions against the real singleton. The +// config is keyed by editor, but nothing here depends on it being a real +// Lexical editor — any identity will do. + +const DIRTY = 'hi' +const editor = { name: "stand-in for a Lexical editor" } + +beforeEach(() => { + DOMPurify.clearConfig() + setSanitizerConfig(editor, [ "span", "p", "strong" ]) +}) + +test("configuring Lexxy's sanitizer leaves the app's per-call config working", () => { + // The app's call still gets exactly what it asked for. + expect(DOMPurify.sanitize(DIRTY, { ALLOW_DATA_ATTR: false })) + .toBe('hi') + + // Control: the option is what's doing the work, so this test can't pass + // because something else happened to strip data-*. + expect(DOMPurify.sanitize(DIRTY, {})).toContain("data-controller") +}) + +test("the app's persistent config does not reach into Lexxy's sanitizing", () => { + DOMPurify.setConfig({ ALLOWED_TAGS: [ "b" ], ALLOWED_ATTR: [] }) + + expect(sanitize('hi', editor)).toBe('hi') +}) + +test("Lexxy's hooks are not installed on the shared instance", () => { + // The style filter hook in config/dom_purify would rewrite this to color + // only, dropping the disallowed property. + expect(DOMPurify.sanitize('x
', { ALLOWED_ATTR: [ "style" ] })) + .toContain("position") +}) + +test("sanitize applies the configured allowlist", () => { + expect(sanitize("keep", editor)).toBe("keep") +}) + +test("each editor keeps its own allowlist", () => { + const other = { name: "a second editor" } + setSanitizerConfig(other, [ "strong" ]) + + expect(sanitize("ab", editor)).toBe("ab") + expect(sanitize("ab", other)).toBe("ab") +}) + +// The regression this change exists for, at attribute rather than tag level. +// +// "each editor keeps its own allowlist" above only varies *tags*, so a +// per-editor rule about an *attribute* could still be shared or misapplied +// without failing anything. `content` is the attribute that matters: it carries +// the attachment's serialized markup, so losing it destroys the attachment on +// the round trip rather than merely trimming it. +// +// Both cases register the *other* editor last on purpose. Under the module-level +// config this replaced, the last editor to connect decided for every editor on +// the page, and each direction below catches one half of that: the first loses an +// attachment that should have survived, the second keeps `content` on an element +// whose own config denied it. +const ATTACHMENT = '' +const ALLOWS_CONTENT = [ { tag: "action-text-attachment", attributes: [ "content", "content-type", "sgid" ] } ] +const DENIES_CONTENT = [ { tag: "action-text-attachment", attributes: [ "content-type", "sgid" ] } ] + +test("an editor allowing attachment content keeps it when another editor denies it", () => { + const allows = { name: "attachments on" } + const denies = { name: "attachments off" } + + setSanitizerConfig(allows, ALLOWS_CONTENT) + setSanitizerConfig(denies, DENIES_CONTENT) // registered last + + expect(sanitize(ATTACHMENT, allows)).toContain("content=") +}) + +test("an editor denying attachment content strips it when another editor allows it", () => { + const denies = { name: "attachments off" } + const allows = { name: "attachments on" } + + setSanitizerConfig(denies, DENIES_CONTENT) + setSanitizerConfig(allows, ALLOWS_CONTENT) // registered last + + const sanitized = sanitize(ATTACHMENT, denies) + + expect(sanitized).not.toContain("content=\"") + // Only the attribute is refused — the element itself is still allowed here. + expect(sanitized).toContain("action-text-attachment") +})