@37signals/lexxy 0.9.30 → 0.9.31-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/lexxy.esm.js +419 -107
  2. package/package.json +3 -2
package/dist/lexxy.esm.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { highlightCode, highlightElement } from './lexxy_helpers.esm.js';
2
- import DOMPurify from 'dompurify';
2
+ import createDOMPurify from 'dompurify';
3
3
  import { getStyleObjectFromCSS, getCSSFromStyleObject, $getSelectionStyleValueForProperty, $ensureForwardRangeSelection, $isAtNodeEnd, $patchStyleText, $setBlocksType, $forEachSelectedTextNode } from '@lexical/selection';
4
4
  import { SKIP_DOM_SELECTION_TAG, CAN_UNDO_COMMAND, COMMAND_PRIORITY_LOW, CAN_REDO_COMMAND, $getSelection, $isRangeSelection, DecoratorNode, $createTextNode, defineExtension, $getRoot, $caretFromPoint, $setSelectionFromCaretRange, $getCaretRange, $normalizeCaret, $getChildCaret, $getCaretInDirection, $isParagraphNode, $isLineBreakNode, $createParagraphNode, $isElementNode, $isRootOrShadowRoot, $isRootNode, $createNodeSelection, $isDecoratorNode, $isTextNode, $getSiblingCaret, $rewindSiblingCaret, $splitAtPointCaretNext, $normalizeSelection__EXPERIMENTAL, $isChildCaret, $isTextPointCaret, $isExtendableTextPointCaret, $isSiblingCaret, $getCommonAncestor, $findMatchingParent, TextNode, createCommand, COMMAND_PRIORITY_EDITOR, $getEditor, $getNodeByKey, HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG, $cloneWithProperties, $getNearestRootOrShadowRoot, $createRangeSelection, $setSelection, createState, COMMAND_PRIORITY_NORMAL, $getState, $setState, $hasUpdateTag, PASTE_TAG, FORMAT_TEXT_COMMAND, UNDO_COMMAND, REDO_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_TAB_COMMAND, INSERT_LINE_BREAK_COMMAND, COMMAND_PRIORITY_HIGH, INSERT_PARAGRAPH_COMMAND, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, $isNodeSelection, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, DELETE_CHARACTER_COMMAND, SELECTION_CHANGE_COMMAND, COMMAND_PRIORITY_CRITICAL, $addUpdateTag, CLICK_COMMAND, isDOMNode, $getNearestNodeFromDOMNode, ElementNode, $splitNode, $getChildCaretAtIndex, $createLineBreakNode, SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, PASTE_COMMAND, $onUpdate, ParagraphNode, RootNode, DRAGSTART_COMMAND, DROP_COMMAND, mergeRegister as mergeRegister$1, $createRangeSelectionFromDom, CLEAR_HISTORY_COMMAND, KEY_ENTER_COMMAND, KEY_SPACE_COMMAND, INPUT_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DOWN_COMMAND } from 'lexical';
5
5
  import * as lexical from 'lexical';
@@ -42,7 +42,250 @@ import 'prismjs/components/prism-bash';
42
42
  import 'prismjs/components/prism-json';
43
43
  import 'prismjs/components/prism-kotlin';
44
44
 
45
- const ALLOWED_HTML_ATTRIBUTES = [ "class", "contenteditable", "href", "src", "style", "title" ];
45
+ function deepMerge(target, source) {
46
+ const result = { ...target, ...source };
47
+ for (const [ key, value ] of Object.entries(source)) {
48
+ if (arePlainHashes(target[key], value)) {
49
+ result[key] = deepMerge(target[key], value);
50
+ }
51
+ }
52
+
53
+ return result
54
+ }
55
+
56
+ function arePlainHashes(...values) {
57
+ return values.every(value => value && value.constructor == Object)
58
+ }
59
+
60
+ class Configuration {
61
+ #tree = {}
62
+
63
+ constructor(...configs) {
64
+ this.merge(...configs);
65
+ }
66
+
67
+ merge(...configs) {
68
+ return this.#tree = configs.reduce(deepMerge, this.#tree)
69
+ }
70
+
71
+ get(path) {
72
+ const keys = path.split(".");
73
+ return keys.reduce((node, key) => node[key], this.#tree)
74
+ }
75
+ }
76
+
77
+ function range(from, to) {
78
+ return [ ...Array(1 + to - from).keys() ].map(i => i + from)
79
+ }
80
+
81
+ const global = new Configuration({
82
+ attachmentTagName: "action-text-attachment",
83
+ attachmentContentTypeNamespace: "actiontext",
84
+ authenticatedUploads: false,
85
+ extensions: []
86
+ });
87
+
88
+ const presets = new Configuration({
89
+ default: {
90
+ attachments: true,
91
+ markdown: true,
92
+ multiLine: true,
93
+ permittedAttachmentTypes: null,
94
+ richText: true,
95
+ toolbar: {
96
+ upload: "both"
97
+ },
98
+ headings: [ "h2", "h3", "h4" ],
99
+ highlight: {
100
+ buttons: {
101
+ color: range(1, 9).map(n => `var(--highlight-${n})`),
102
+ "background-color": range(1, 9).map(n => `var(--highlight-bg-${n})`),
103
+ },
104
+ permit: {
105
+ color: [],
106
+ "background-color": []
107
+ }
108
+ }
109
+ }
110
+ });
111
+
112
+ var Lexxy = {
113
+ global,
114
+ presets,
115
+ configure({ global: newGlobal, ...newPresets }) {
116
+ if (newGlobal) {
117
+ global.merge(newGlobal);
118
+ }
119
+ presets.merge(newPresets);
120
+ }
121
+ };
122
+
123
+ // An attachment's `url` ends up as an <img src> (action_text_attachment_node
124
+ // reads it into `this.src`, which is assigned to img.src). DOMPurify already
125
+ // permits a data: URI on img[src] — img is in its DATA_URI_TAGS — but it has no
126
+ // way to know that a custom element's `url` feeds the same sink, so it applies
127
+ // the plain URI check and drops it.
128
+ //
129
+ // Until 3.3.2 that never came up: attributes admitted by a *functional* ADD_ATTR
130
+ // skipped URI validation entirely (GHSA-cjmm-f4jc-qw8r), which is how data:
131
+ // URLs worked here — and, less happily, how `url="javascript:…"` survived too.
132
+ // The fix restored validation for both.
133
+ //
134
+ // So `url` is marked URI-safe, which hands the decision to this hook. The hook
135
+ // only ever removes an attribute — it never force-keeps one — so scoping stays
136
+ // with the ADD_ATTR predicate, and a `url` on a tag that never declared it is
137
+ // dropped as it always was.
138
+ //
139
+ // The data: exception is scoped to the attachment element, not to the attribute
140
+ // name. ADD_URI_SAFE_ATTR is attribute-name-wide: it takes `url` out of
141
+ // DOMPurify's URI checking on every tag, not just ours. Extensions may declare
142
+ // arbitrary attributes on arbitrary tags — home/docs/extensions.md's own worked
143
+ // example is an <iframe> — so an extension declaring `url` would otherwise
144
+ // inherit an exception granted because *an attachment's* url becomes an img src.
145
+ // Nothing about a third party's element supports that, and `data:text/html` in a
146
+ // navigational sink is script execution. Everything else gets the standard
147
+ // policy, which is what it would have had if we had never touched `url`.
148
+ //
149
+ // That argument is about an attribute feeding a URL sink, not about
150
+ // ADD_URI_SAFE_ATTR as such. The inert text attributes beside `url` in that list
151
+ // — `caption`, `filename` — keep the attribute-name-wide exemption, and need it:
152
+ // ordinary prose like "Q4: results" is not a URI any regex here would accept.
153
+ const URI_BEARING_ATTACHMENT_ATTRIBUTES = [ "url" ];
154
+
155
+ // DOMPurify's own IS_ALLOWED_URI, reproduced rather than narrowed, so `url` on a
156
+ // non-attachment tag is treated exactly as DOMPurify would have treated it.
157
+ const ALLOWED_URI = /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.-]+(?:[^a-z+.:-]|$))/i;
158
+
159
+ // eslint-disable-next-line no-control-regex -- mirrors DOMPurify's own ATTR_WHITESPACE
160
+ const ATTR_WHITESPACE = /[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g;
161
+
162
+ // Tested against the value as given, never the whitespace-stripped copy, so a
163
+ // scheme smuggled at a nonzero offset is refused. DOMPurify draws the same line:
164
+ // its data: allowance is a prefix test on the un-stripped value, which is why
165
+ // this is a second step rather than one more scheme in ALLOWED_URI.
166
+ //
167
+ // One deliberate difference from DOMPurify's img[src] handling remains, stated
168
+ // because the earlier claim of matching it "exactly" was not true: DOMPurify
169
+ // tests for a literal lowercase `data:` and this is case-insensitive, so `DATA:`
170
+ // passes here too. That matches how browsers resolve schemes, which is what
171
+ // actually decides whether the URL loads.
172
+ const ATTACHMENT_DATA_URI = /^data:/i;
173
+
174
+ function isAttachmentTag(tag) {
175
+ return tag === Lexxy.global.get("attachmentTagName")
176
+ }
177
+
178
+ function attachmentUriFilterHook(currentNode, hookEvent) {
179
+ if (!URI_BEARING_ATTACHMENT_ATTRIBUTES.includes(hookEvent.attrName)) return
180
+
181
+ // DOMPurify keeps an empty value — its chain ends `else if (value) { return
182
+ // false } else ;` — while every alternation here needs at least one character.
183
+ if (!hookEvent.attrValue) return
184
+
185
+ const value = String(hookEvent.attrValue);
186
+
187
+ if (ALLOWED_URI.test(value.replace(ATTR_WHITESPACE, ""))) return
188
+ if (isAttachmentTag(currentNode?.nodeName?.toLowerCase()) && ATTACHMENT_DATA_URI.test(value)) return
189
+
190
+ hookEvent.keepAttr = false;
191
+ }
192
+
193
+ // Lexxy's own DOMPurify instance, deliberately not the shared default export.
194
+ //
195
+ // dompurify's default export is a singleton, and both its config and its hooks
196
+ // are global to every consumer in the bundle. That makes configuring it from an
197
+ // editor's connectedCallback actively dangerous for the host app: DOMPurify
198
+ // treats a persistent config as final, so once setConfig() has run, every
199
+ // later `sanitize(html, config)` anywhere in the app silently ignores its own
200
+ // config argument. An app sanitizing untrusted HTML with, say,
201
+ // `{ ALLOW_DATA_ATTR: false }` would keep passing that option and stop getting
202
+ // it the moment a Lexxy editor connected — with no error and no visible change
203
+ // at the call site.
204
+ //
205
+ // Calling the default export with a window returns a fresh, independent
206
+ // instance. This one carries the hooks and config below; nothing we do here can
207
+ // reach the app's instance, and nothing it does can reach ours.
208
+ //
209
+ // Under Trusted Types, every DOMPurify instance tries to create a policy named
210
+ // `dompurify` on its first sanitize, and TT rejects a duplicate name — so the
211
+ // second instance on the page gets none. That matters, and not because it throws:
212
+ // DOMPurify hands its input to DOMParser.parseFromString, which is itself a TT
213
+ // sink, but `_initDocument` swallows that throw and the innerHTML throw from its
214
+ // createDocument fallback, and `sanitize` then returns "" for the body it never
215
+ // got. An unsigned instance silently drops all content, with nothing in the host's
216
+ // error tracker to say why — and which sanitizer goes silent depends on which one
217
+ // ran first.
218
+ //
219
+ // So we create our own, under our own name, and hand it to DOMPurify rather than
220
+ // letting it try. Lazily, because resolving it at import would fire a CSP
221
+ // violation report on every page load of an app that imports Lexxy and never
222
+ // renders an editor, and at most once, because TT throws on a duplicate name.
223
+ // Guarded too, because creating a policy the CSP hasn't allowlisted throws: if
224
+ // that happens we're back to no policy, which is exactly where this stood before.
225
+ // An app enforcing `require-trusted-types-for 'script'` should add `lexxy` to its
226
+ // `trusted-types` directive.
227
+ //
228
+ // What this does NOT do is make Lexxy work under enforced Trusted Types. It
229
+ // stops *our* sanitizer from taking the host's policy name and breaking the
230
+ // host's; it does nothing about Lexxy's own unwrapped sinks, and there are
231
+ // several. `parseHtml` in helpers/html_helper.js hands a plain string to
232
+ // DOMParser.parseFromString on the initial-value path, so the editor throws
233
+ // before it finishes connecting — verified in Chromium under
234
+ // `require-trusted-types-for 'script'`, with `lexxy` allowlisted and without.
235
+ // `createElement` in the same file is a second sink in it, writing its `content`
236
+ // argument through innerHTML for the two callers that pass one — the wrapped-table
237
+ // figure and the table tools' count. `highlightElement` in
238
+ // helpers/code_highlighting_helper.js writes Prism's output the same way, and both
239
+ // it and highlightCode are exported from src/index.js — so that one throws for a
240
+ // host app calling Lexxy's highlighting API directly, outside any editor. The
241
+ // `insertAdjacentHTML` in nodes/custom_action_text_attachment_node.js and the
242
+ // `innerHTML` writes across elements/ — the toolbar, the dropdowns, the node
243
+ // delete button — are in the same position. Making the editor usable under TT is a
244
+ // separate piece of work; this is a prerequisite for it, not the whole of it.
245
+ let trustedTypesPolicyResolved = false;
246
+ let resolvedTrustedTypesPolicy = null;
247
+
248
+ function trustedTypesPolicy() {
249
+ if (!trustedTypesPolicyResolved) {
250
+ resolvedTrustedTypesPolicy = createTrustedTypesPolicy();
251
+ trustedTypesPolicyResolved = true;
252
+ }
253
+
254
+ return resolvedTrustedTypesPolicy
255
+ }
256
+
257
+ function createTrustedTypesPolicy() {
258
+ // Feature-detected below, so browsers without Trusted Types simply get no
259
+ // policy — the same path as a CSP that doesn't allowlist ours.
260
+ // eslint-disable-next-line compat/compat
261
+ const trustedTypes = window.trustedTypes;
262
+
263
+ if (typeof trustedTypes?.createPolicy !== "function") return null
264
+
265
+ try {
266
+ return trustedTypes.createPolicy("lexxy", { createHTML: (html) => html, createScriptURL: (url) => url })
267
+ } catch {
268
+ // Warned rather than swallowed, matching what DOMPurify does when its own
269
+ // policy is refused. The fallback is a silent loss of Trusted Types coverage
270
+ // otherwise, and the CSP violation report alone doesn't name us.
271
+ console.warn("TrustedTypes policy lexxy could not be created.");
272
+ return null
273
+ }
274
+ }
275
+
276
+ const DOMPurify = createDOMPurify(window);
277
+
278
+ // alt is inert on every element it can appear on, so it sits in the blanket
279
+ // list. srcset is deliberately absent — it carries URLs, so it belongs to a
280
+ // consumer that declares it.
281
+ const ALLOWED_HTML_ATTRIBUTES = [ "alt", "class", "contenteditable", "href", "src", "style", "title" ];
282
+
283
+ // width/height are scoped to img rather than allowlisted globally, because
284
+ // ALLOWED_ATTR is not per-tag: putting them there would also permit
285
+ // `<table width="100000">` and `<td height="500">` in attachment content, which
286
+ // is layout the editor previously stripped. An image needs them to hold its
287
+ // place while it loads; nothing else here does.
288
+ const DEFAULT_TAG_ATTRIBUTES = { img: [ "width", "height" ] };
46
289
 
47
290
  const ALLOWED_STYLE_PROPERTIES = [ "color", "background-color" ];
48
291
 
@@ -66,15 +309,17 @@ function styleFilterHook(_currentNode, hookEvent) {
66
309
  }
67
310
 
68
311
  DOMPurify.addHook("uponSanitizeAttribute", styleFilterHook);
312
+ DOMPurify.addHook("uponSanitizeAttribute", attachmentUriFilterHook);
69
313
 
70
314
  const FORBIDDEN_STIMULUS_ATTRIBUTES = [ "data-controller", "data-action" ];
71
315
 
72
316
  // Stimulus behavior attributes must never survive sanitization, whatever an
73
- // extension's allowedElements declares. FORBID_ATTR alone isn't enough: in
74
- // DOMPurify 3.x the functional ADD_ATTR — which Lexxy builds from the public
75
- // allowedElements API — is evaluated ahead of FORBID_ATTR, so an extension that
76
- // listed one of these on a tag would otherwise reinstate it. This hook drops
77
- // them unconditionally, keeping the class-level prohibition config-independent.
317
+ // extension's allowedElements declares. On dompurify 3.4.13 FORBID_ATTR already
318
+ // carries that on its own: _isValidAttribute opens with it, ahead of the
319
+ // functional ADD_ATTR Lexxy builds from the public allowedElements API. So this
320
+ // hook is defence in depth rather than the barrier, and it is kept because it
321
+ // holds without reference to the config — the prohibition is a class-level one,
322
+ // and a FORBID_ATTR entry lives or dies with whatever rebuilds the config.
78
323
  function stimulusAttributeFilterHook(_currentNode, hookEvent) {
79
324
  if (FORBIDDEN_STIMULUS_ATTRIBUTES.includes(hookEvent.attrName)) {
80
325
  hookEvent.keepAttr = false;
@@ -89,29 +334,70 @@ DOMPurify.addHook("uponSanitizeElement", (node, data) => {
89
334
  }
90
335
  });
91
336
 
92
- function buildConfig(allowedElements ) {
93
- const tagAttributes = {};
337
+ // Called with no allowedElements for a sanitizer that has no allowlist to apply,
338
+ // which is not the same thing as an empty one — see EditorSanitizer's fallback.
339
+ function buildConfig(allowedElements = null) {
340
+ const config = {
341
+ ADD_URI_SAFE_ATTR: [ "caption", "filename", ...URI_BEARING_ATTACHMENT_ATTRIBUTES ],
342
+ SAFE_FOR_XML: false, // So that it does not strip attributes that contains serialized HTML (like content)
343
+ // Stimulus behavior attributes must never survive sanitization: they let stored content
344
+ // wire up arbitrary controllers/actions in the viewer's session. FORBID_ATTR wins over
345
+ // ALLOWED_ATTR/ADD_ATTR/ALLOW_DATA_ATTR in DOMPurify, so this holds even though other
346
+ // data-* attributes (data-language, data-trix-*, etc.) are otherwise allowed through.
347
+ FORBID_ATTR: [ "data-controller", "data-action" ]
348
+ };
349
+
350
+ // Left out rather than emptied when there is no allowlist, so DOMPurify's own
351
+ // default tag and attribute policy stands. `ALLOWED_TAGS: []` would not be a
352
+ // default, it would be a refusal: it strips every tag. An editor that declares
353
+ // an empty allowlist still gets that refusal, because it asked for it.
354
+ if (allowedElements) Object.assign(config, allowlistFor(allowedElements));
94
355
 
356
+ // Always assigned, including when we have no policy — `null` is what
357
+ // trustedTypesPolicy() returns then, and `TRUSTED_TYPES_POLICY: null` is
358
+ // DOMPurify's documented per-call opt-out: sign nothing, create nothing.
359
+ //
360
+ // Leaving the key out is a different thing entirely, and the wrong one. With no
361
+ // key DOMPurify falls through to _getDefaultTrustedTypesPolicy() and asks the
362
+ // browser for `dompurify` — the very name this exists to stop competing for — so
363
+ // an omitted key would disarm the sanitizer of a host shipping
364
+ // `trusted-types dompurify` on the one path where we couldn't get our own.
365
+ // Present-and-`undefined` lands in that same fallthrough, so it is not a
366
+ // substitute for `null` either.
367
+ config.TRUSTED_TYPES_POLICY = trustedTypesPolicy();
368
+
369
+ return config
370
+ }
371
+
372
+ function allowlistFor(allowedElements) {
373
+ // Null prototype, so a declared tag can never read through to an
374
+ // Object.prototype key: `tagAttributes["constructor"]` would answer with a
375
+ // function, and ADD_ATTR would call .includes on it.
376
+ const tagAttributes = Object.create(null);
377
+
378
+ // Lowercased, because DOMPurify lowercases ALLOWED_TAGS and calls ADD_ATTR
379
+ // with the lowercased tag and attribute names. Keeping the caller's casing
380
+ // makes allowedElements silently partial: `[ "IMG" ]` allows the element but
381
+ // drops the width/height below, and `[ { tag: "img", attributes: [ "GID" ] } ]`
382
+ // drops the attribute it declares.
95
383
  for (const element of allowedElements) {
96
- if (typeof element === "string") {
97
- tagAttributes[element] ||= [];
98
- } else {
99
- tagAttributes[element.tag] ||= [];
100
- tagAttributes[element.tag].push(...element.attributes);
101
- }
384
+ const tag = String(element.tag ?? element).toLowerCase();
385
+ const attributes = (element.attributes ?? []).map(attribute => attribute.toLowerCase());
386
+
387
+ tagAttributes[tag] ||= [];
388
+ tagAttributes[tag].push(...attributes);
389
+ }
390
+
391
+ // Only for tags the caller already permits — this widens what an allowed
392
+ // element may carry, never which elements are allowed.
393
+ for (const [ tag, attributes ] of Object.entries(DEFAULT_TAG_ATTRIBUTES)) {
394
+ if (tagAttributes[tag]) tagAttributes[tag].push(...attributes);
102
395
  }
103
396
 
104
397
  return {
105
398
  ALLOWED_TAGS: Object.keys(tagAttributes),
106
399
  ALLOWED_ATTR: ALLOWED_HTML_ATTRIBUTES,
107
- ADD_ATTR: (attribute, tag) => tagAttributes[tag]?.includes(attribute),
108
- ADD_URI_SAFE_ATTR: [ "caption", "filename" ],
109
- SAFE_FOR_XML: false, // So that it does not strip attributes that contains serialized HTML (like content)
110
- // Stimulus behavior attributes must never survive sanitization: they let stored content
111
- // wire up arbitrary controllers/actions in the viewer's session. FORBID_ATTR wins over
112
- // ALLOWED_ATTR/ADD_ATTR/ALLOW_DATA_ATTR in DOMPurify, so this holds even though other
113
- // data-* attributes (data-language, data-trix-*, etc.) are otherwise allowed through.
114
- FORBID_ATTR: [ "data-controller", "data-action" ]
400
+ ADD_ATTR: (attribute, tag) => tagAttributes[tag]?.includes(attribute)
115
401
  }
116
402
  }
117
403
 
@@ -1377,91 +1663,111 @@ class LinkDropdown extends ToolbarDropdown {
1377
1663
  }
1378
1664
  }
1379
1665
 
1380
- function deepMerge(target, source) {
1381
- const result = { ...target, ...source };
1382
- for (const [ key, value ] of Object.entries(source)) {
1383
- if (arePlainHashes(target[key], value)) {
1384
- result[key] = deepMerge(target[key], value);
1385
- }
1386
- }
1387
-
1388
- return result
1389
- }
1666
+ // An editor's sanitizer: the allowlist it was registered with, applied to a
1667
+ // string.
1668
+ //
1669
+ // The allowlist is per editor, and passed to each sanitize() call. Neither of
1670
+ // those is incidental. This used to be a module-level config installed with
1671
+ // DOMPurify.setConfig() on the shared singleton, which had two distinct
1672
+ // consequences:
1673
+ //
1674
+ // 1. A persistent config is final — DOMPurify ignores the per-call config once
1675
+ // one is set — so it silently disarmed the sanitizing of any host app that
1676
+ // also imports dompurify. See config/dom_purify for why we now own our
1677
+ // instance; keeping the config per-call means there is no global sanitizer
1678
+ // state left even on that instance.
1679
+ //
1680
+ // 2. One config for the whole module meant the last editor to connect decided
1681
+ // how every other editor on the page sanitized. That is not cosmetic: an
1682
+ // editor's `value` is sanitized on read, so a rich editor sharing a page with
1683
+ // a plain one would silently drop its own headings, lists and links from the
1684
+ // value it submits.
1685
+ //
1686
+ // Sanitizers are keyed by the Lexical editor, which is the identity both call
1687
+ // sites have: the element registers with it and keeps the result as
1688
+ // `this.sanitizer`, while nodes only receive it as createDOM()'s second argument
1689
+ // and resolve their sanitizer with for().
1690
+ class EditorSanitizer {
1691
+ static #instances = new WeakMap()
1692
+
1693
+ // Only reached for an editor that never registered, which is a supported case:
1694
+ // CustomActionTextAttachmentNode is exported from src/index.js, so it can be
1695
+ // registered in a Lexical editor Lexxy did not build.
1696
+ //
1697
+ // Fixed, and never reassigned. Pointing it at whichever sanitizer registered
1698
+ // most recently would make an unregistered consumer's allowlist depend on
1699
+ // which Lexxy editor happened to initialise last — the last-editor-wins bug
1700
+ // this class exists to remove, reintroduced on the one path that has no editor
1701
+ // to key on, and unobservable from the consumer's own code.
1702
+ //
1703
+ // It declares no allowlist, so DOMPurify's own default policy applies. An
1704
+ // empty allowlist would be deterministic too and strips every tag, which
1705
+ // silently drops markup a standalone consumer used to keep. It holds no
1706
+ // editor either, so nothing here pins a disconnected editor's DOM.
1707
+ //
1708
+ // A consumer wanting its own allowlist registers for it: register() needs only
1709
+ // the Lexical editor and is reachable as EditorSanitizer from src/index.js.
1710
+ //
1711
+ // Built on first use rather than as a static field initializer, which would run
1712
+ // buildConfig at import: src/index.js exports this class, so that would create
1713
+ // the Trusted Types policy — and, unallowlisted, report a CSP violation — on
1714
+ // every page load of an app that never renders an editor.
1715
+ static #fallback
1390
1716
 
1391
- function arePlainHashes(...values) {
1392
- return values.every(value => value && value.constructor == Object)
1393
- }
1717
+ static #fallbackSanitizer() {
1718
+ return this.#fallback ||= new EditorSanitizer()
1719
+ }
1394
1720
 
1395
- class Configuration {
1396
- #tree = {}
1721
+ static register(editor, allowedElements = []) {
1722
+ const sanitizer = new EditorSanitizer(this.#allowedElementsFor(editor, allowedElements));
1723
+ this.#instances.set(editor, sanitizer);
1397
1724
 
1398
- constructor(...configs) {
1399
- this.merge(...configs);
1725
+ return sanitizer
1400
1726
  }
1401
1727
 
1402
- merge(...configs) {
1403
- return this.#tree = configs.reduce(deepMerge, this.#tree)
1728
+ static for(editor) {
1729
+ return this.#instances.get(editor) ?? this.#fallbackSanitizer()
1404
1730
  }
1405
1731
 
1406
- get(path) {
1407
- const keys = path.split(".");
1408
- return keys.reduce((node, key) => node[key], this.#tree)
1732
+ // An editor can import every tag it can convert from HTML, plus whatever its
1733
+ // extensions declare. Lexical registers the conversions while building the
1734
+ // editor, so they are already in place when $initialEditorState registers us.
1735
+ static #allowedElementsFor(editor, allowedElements) {
1736
+ return this.#importableTags(editor).concat(allowedElements)
1409
1737
  }
1410
- }
1411
1738
 
1412
- function range(from, to) {
1413
- return [ ...Array(1 + to - from).keys() ].map(i => i + from)
1414
- }
1739
+ static #importableTags(editor) {
1740
+ const tags = Array.from(editor._htmlConversions.keys());
1741
+ return tags.filter(tag => !tag.startsWith("#"))
1742
+ }
1415
1743
 
1416
- const global = new Configuration({
1417
- attachmentTagName: "action-text-attachment",
1418
- attachmentContentTypeNamespace: "actiontext",
1419
- authenticatedUploads: false,
1420
- extensions: []
1421
- });
1744
+ #config
1422
1745
 
1423
- const presets = new Configuration({
1424
- default: {
1425
- attachments: true,
1426
- markdown: true,
1427
- multiLine: true,
1428
- permittedAttachmentTypes: null,
1429
- richText: true,
1430
- toolbar: {
1431
- upload: "both"
1432
- },
1433
- headings: [ "h2", "h3", "h4" ],
1434
- highlight: {
1435
- buttons: {
1436
- color: range(1, 9).map(n => `var(--highlight-${n})`),
1437
- "background-color": range(1, 9).map(n => `var(--highlight-bg-${n})`),
1438
- },
1439
- permit: {
1440
- color: [],
1441
- "background-color": []
1442
- }
1443
- }
1746
+ constructor(allowedElements = null) {
1747
+ this.#config = buildConfig(allowedElements);
1444
1748
  }
1445
- });
1446
1749
 
1447
- var Lexxy = {
1448
- global,
1449
- presets,
1450
- configure({ global: newGlobal, ...newPresets }) {
1451
- if (newGlobal) {
1452
- global.merge(newGlobal);
1750
+ // safeForXml opts into DOMPurify's mXSS-safe mode, for a caller re-inflating
1751
+ // stored content. It is off by default, and the reason is not that the default
1752
+ // hop is trustworthy — the value an editor reads back carries the attacker's
1753
+ // stored `content` verbatim. The reason is that strictness there is destructive:
1754
+ // SAFE_FOR_XML drops any attribute whose value could close a comment, a
1755
+ // serialized `content` full of Rails view annotations is exactly that, and
1756
+ // CustomActionTextAttachmentNode.importDOM returns null for an attachment with no
1757
+ // `content` — so the attachment vanishes on the next edit. Verified: strict on the
1758
+ // value hop leaves `<action-text-attachment sgid content-type>` and re-editing that
1759
+ // renders nothing.
1760
+ //
1761
+ // The strict config is this instance's own, spread rather than rebuilt, so the
1762
+ // per-editor allowlist and everything else buildConfig put there — the Trusted
1763
+ // Types policy, ADD_URI_SAFE_ATTR — survive the flip.
1764
+ sanitize(html, { safeForXml = false } = {}) {
1765
+ if (safeForXml) {
1766
+ return DOMPurify.sanitize(html, { ...this.#config, SAFE_FOR_XML: true })
1453
1767
  }
1454
- presets.merge(newPresets);
1455
- }
1456
- };
1457
-
1458
- function setSanitizerConfig(allowedTags) {
1459
- DOMPurify.clearConfig();
1460
- DOMPurify.setConfig(buildConfig(allowedTags));
1461
- }
1462
1768
 
1463
- function sanitize(html) {
1464
- return DOMPurify.sanitize(html)
1769
+ return DOMPurify.sanitize(html, this.#config)
1770
+ }
1465
1771
  }
1466
1772
 
1467
1773
  function bytesToHumanSize(bytes) {
@@ -1560,11 +1866,26 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
1560
1866
  this.plainText = plainText ?? extractPlainTextFromHtml(innerHtml);
1561
1867
  }
1562
1868
 
1563
- createDOM() {
1869
+ createDOM(_config, editor) {
1564
1870
  const figure = createElement(this.tagName, { "content-type": this.contentType, "data-lexxy-decorator": true, draggable: true });
1565
1871
  figure.dataset.lexicalNodeKey = this.__key;
1566
1872
 
1567
- figure.insertAdjacentHTML("beforeend", sanitize(this.innerHtml));
1873
+ // Resolved from the editor so this content is sanitized with its own
1874
+ // allowlist rather than whichever editor connected most recently.
1875
+ //
1876
+ // this.innerHtml is untrusted stored content being re-inflated into the editor,
1877
+ // so it goes through DOMPurify's mXSS-safe mode. Strictness is free here because
1878
+ // of where the `content` attribute that has to survive is sanitized, which is not
1879
+ // this hop: it is produced by exportDOM and by the server-side pass in
1880
+ // lib/lexxy/rich_text_area_tag.rb, and it is only ever sanitized on the lax hop
1881
+ // where an editor reads its own value back.
1882
+ //
1883
+ // The decoded inner markup can carry a `content` attribute of its own — nested
1884
+ // attachment markup does, and the attribute is allowlisted on the attachment tag
1885
+ // in extensions/attachments_extension.js — and mXSS-safe mode drops it. That loss
1886
+ // is cosmetic: it is a nested attachment's rendering inside this one, not the
1887
+ // attribute anything re-imports from.
1888
+ figure.insertAdjacentHTML("beforeend", EditorSanitizer.for(editor).sanitize(this.innerHtml, { safeForXml: true }));
1568
1889
 
1569
1890
  const deleteButton = createElement("lexxy-node-delete-button");
1570
1891
  figure.appendChild(deleteButton);
@@ -9373,7 +9694,7 @@ class LexicalEditorElement extends HTMLElement {
9373
9694
 
9374
9695
  #readSanitizedEditorValue() {
9375
9696
  return this.editor?.read(() => {
9376
- return sanitize($generateHtmlFromNodes(this.editor, null))
9697
+ return this.sanitizer.sanitize($generateHtmlFromNodes(this.editor, null))
9377
9698
  }) ?? null
9378
9699
  }
9379
9700
 
@@ -9760,16 +10081,7 @@ class LexicalEditorElement extends HTMLElement {
9760
10081
  }
9761
10082
 
9762
10083
  #configureSanitizer(editor) {
9763
- setSanitizerConfig(this.#getAllowedElements(editor));
9764
- }
9765
-
9766
- #getAllowedElements(editor) {
9767
- return this.#getImportableTags(editor).concat(this.extensions.allowedElements)
9768
- }
9769
-
9770
- #getImportableTags(editor) {
9771
- const tags = Array.from(editor._htmlConversions.keys());
9772
- return tags.filter(tag => !tag.startsWith("#"))
10084
+ this.sanitizer = EditorSanitizer.register(editor, this.extensions.allowedElements);
9773
10085
  }
9774
10086
 
9775
10087
  #dispatchAttributesChange() {
@@ -11714,4 +12026,4 @@ const configure = Lexxy.configure;
11714
12026
  // Pushing elements definition to after the current call stack to allow global configuration to take place first
11715
12027
  setTimeout(defineElements, 0);
11716
12028
 
11717
- export { $createActionTextAttachmentNode, $createActionTextAttachmentUploadNode, $isActionTextAttachmentNode, $isCustomActionTextAttachmentNode, ActionTextAttachmentNode, ActionTextAttachmentUploadNode, CustomActionTextAttachmentNode, LexxyExtension as Extension, HorizontalDividerNode, NativeAdapter, REWRITE_HISTORY_COMMAND, configure };
12029
+ export { $createActionTextAttachmentNode, $createActionTextAttachmentUploadNode, $isActionTextAttachmentNode, $isCustomActionTextAttachmentNode, ActionTextAttachmentNode, ActionTextAttachmentUploadNode, CustomActionTextAttachmentNode, EditorSanitizer, LexxyExtension as Extension, HorizontalDividerNode, NativeAdapter, REWRITE_HISTORY_COMMAND, configure };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.9.30",
3
+ "version": "0.9.31-beta",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",
@@ -51,6 +51,7 @@
51
51
  "test:browser:webkit": "npx playwright test --config test/browser/playwright.config.js --project=webkit",
52
52
  "test:browser:headed": "npx playwright test --config test/browser/playwright.config.js --headed",
53
53
  "test:browser:debug": "npx playwright test --config test/browser/playwright.config.js --debug",
54
+ "prepare": "rollup -c rollup.config.npm.mjs",
54
55
  "prerelease": "yarn build:npm",
55
56
  "release": "yarn build:npm && yarn publish",
56
57
  "release:alpha": "yarn build:npm && yarn publish --tag alpha"
@@ -69,7 +70,7 @@
69
70
  "@lexical/selection": "^0.44.0",
70
71
  "@lexical/table": "^0.44.0",
71
72
  "@lexical/utils": "^0.44.0",
72
- "dompurify": "^3.3.0",
73
+ "dompurify": "^3.4.13",
73
74
  "lexical": "^0.44.0",
74
75
  "marked": "^16.4.1",
75
76
  "prismjs": "^1.30.0"