-
Notifications
You must be signed in to change notification settings - Fork 114
Give Lexxy its own DOMPurify instance, and an allowlist per editor #1231
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
jeremy
wants to merge
2
commits into
attachment-content-image-attributes
from
sanitizer-instance-and-per-editor-allowlist
Draft
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| <!DOCTYPE html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <meta name="viewport" content="width=device-width,initial-scale=1"> | ||
| <title>Lexxy Test — sanitizer isolation</title> | ||
| <link rel="stylesheet" href="/styles.css"> | ||
| </head> | ||
| <body> | ||
| <form> | ||
| <div class="body"> | ||
| <lexxy-editor id="rich" class="lexxy-content" placeholder="Rich"></lexxy-editor> | ||
| </div> | ||
|
|
||
| <!-- Connects second, so under the old module-level config it was this | ||
| editor's allowlist that governed the rich one above. --> | ||
| <div class="body"> | ||
| <lexxy-editor id="plain" preset="plain" class="lexxy-content" placeholder="Plain"></lexxy-editor> | ||
| </div> | ||
|
|
||
| <div class="events"></div> | ||
| </form> | ||
|
|
||
| <script type="module" src="/sanitizer-isolation.js"></script> | ||
| </body> | ||
| </html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import { configure } from "lexxy" | ||
| import "./events_logger.js" | ||
|
|
||
| // A plain-text preset alongside the default rich one. The two resolve different | ||
| // importable tags, which is what makes their sanitizer allowlists differ. | ||
| configure({ plain: { richText: false } }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| import { test } from "../../test_helper.js" | ||
| import { EditorHandle } from "../../helpers/editor_handle.js" | ||
| import { expect } from "@playwright/test" | ||
|
|
||
| // Each editor sanitizes with its own allowlist. | ||
| // | ||
| // The config used to be one module-level value installed with | ||
| // DOMPurify.setConfig(), so the last editor to connect decided how every editor | ||
| // on the page sanitized. An editor's `value` is sanitized on read, so a rich | ||
| // editor sharing a page with a plain one silently dropped its own headings and | ||
| // lists from the value it submitted — with nothing different on screen, because | ||
| // the editor DOM was never the thing being rewritten. | ||
| // | ||
| // The unit test for this runs in JSDOM. This one exists because the failure | ||
| // involves the custom-element lifecycle, cached value reads and the bundled | ||
| // build, none of which JSDOM proves anything about. | ||
| test.describe("sanitizer isolation between editors", () => { | ||
| const RICH = "<h1>Title</h1><ul><li>one</li></ul><p>body</p>" | ||
|
|
||
| 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("<h1>") | ||
| expect(value).toContain("<ul>") | ||
| }) | ||
|
|
||
| test("the plain editor still sanitizes with its own narrower allowlist", 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 plain.setValue(RICH) | ||
| await plain.click() | ||
| await plain.send("!") | ||
|
|
||
| // Control: proves the two editors really do resolve different allowlists, so | ||
| // the assertion above isn't passing because both are simply permissive. | ||
| expect(await plain.value()).not.toContain("<h1>") | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| import { afterEach, expect, test } from "vitest" | ||
| import { $getRoot } from "lexical" | ||
| import Lexxy from "src/config/lexxy" | ||
| import { createTestEditor, destroyTestEditor, setContent, tick } from "../helpers/editor_helper" | ||
|
|
||
| // Each editor sanitizes with its own allowlist. | ||
| // | ||
| // The sanitizer config used to be one module-level value installed with | ||
| // DOMPurify.setConfig(), so the last editor to connect decided how every other | ||
| // editor on the page sanitized. Because an editor's `value` is sanitized on | ||
| // read, a rich editor sharing a page with a plain one would silently drop its | ||
| // own headings and lists from the value it submitted — data loss on save, with | ||
| // nothing visible in the editor to suggest it. | ||
|
|
||
| const RICH = "<h1>Title</h1><ul><li>one</li></ul><p>body</p>" | ||
|
|
||
| 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("<h1>") | ||
| expect(rich.value).toContain("<ul>") | ||
| }) | ||
|
|
||
| test("the plain editor still sanitizes with its own narrower allowlist", async () => { | ||
| Lexxy.configure({ plain: { richText: false } }) | ||
|
|
||
| rich = await createTestEditor({ value: RICH }) | ||
| plain = await createTestEditor({ attributes: { preset: "plain" }, value: RICH }) | ||
| await tick() | ||
|
|
||
| await typeInto(plain) | ||
|
|
||
| // Control: proves the two editors really do resolve different allowlists, so | ||
| // the assertion above isn't passing because both are simply permissive. | ||
| expect(plain.value).not.toContain("<h1>") | ||
| }) | ||
|
|
||
| test("attachment content is sanitized with its own editor's allowlist", async () => { | ||
| Lexxy.configure({ plain: { richText: false } }) | ||
|
|
||
| const attachment = '<action-text-attachment content-type="application/vnd.test.thing" ' + | ||
| 'content="<blockquote>quoted</blockquote>"></action-text-attachment>' | ||
|
|
||
| rich = await createTestEditor() | ||
| plain = await createTestEditor({ attributes: { preset: "plain" } }) | ||
| await tick() | ||
|
|
||
| // The content has to be set *after* the plain editor connects. A decorator | ||
| // node builds its DOM once, so loading the attachment up front would render | ||
| // it before there was any competing config — passing whether or not the bug | ||
| // is present. | ||
| await setContent(rich, `<p>${attachment}</p>`) | ||
|
|
||
| // blockquote is in the rich editor's allowlist but not the plain one's. | ||
| expect(rich.querySelector("action-text-attachment").innerHTML).toContain("<blockquote>") | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = '<span data-controller="evil" class="keep">hi</span>' | ||
| 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('<span class="keep">hi</span>') | ||
|
|
||
| // 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('<span class="keep">hi</span>', editor)).toBe('<span class="keep">hi</span>') | ||
| }) | ||
|
|
||
| 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('<p style="color: red; position: fixed">x</p>', { ALLOWED_ATTR: [ "style" ] })) | ||
| .toContain("position") | ||
| }) | ||
|
|
||
| test("sanitize applies the configured allowlist", () => { | ||
| expect(sanitize("<span>keep</span><script>evil()</script>", editor)).toBe("<span>keep</span>") | ||
| }) | ||
|
|
||
| test("each editor keeps its own allowlist", () => { | ||
| const other = { name: "a second editor" } | ||
| setSanitizerConfig(other, [ "strong" ]) | ||
|
|
||
| expect(sanitize("<span>a</span><strong>b</strong>", editor)).toBe("<span>a</span><strong>b</strong>") | ||
| expect(sanitize("<span>a</span><strong>b</strong>", other)).toBe("a<strong>b</strong>") | ||
| }) | ||
|
|
||
| // 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 = '<action-text-attachment sgid="x" content-type="text/html" content="<span>hi</span>"></action-text-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") | ||
| }) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.