@37signals/lexxy 0.9.30 → 0.9.31

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 +472 -106
  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,283 @@ 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. The bypass was reported to us by
133
+ // @petitpois via HackerOne.
134
+ //
135
+ // So `url` is marked URI-safe, which hands the decision to this hook. The hook
136
+ // only ever removes an attribute — it never force-keeps one — so scoping stays
137
+ // with the ADD_ATTR predicate, and a `url` on a tag that never declared it is
138
+ // dropped as it always was.
139
+ //
140
+ // The data: exception is scoped to the attachment element, not to the attribute
141
+ // name. ADD_URI_SAFE_ATTR is attribute-name-wide: it takes `url` out of
142
+ // DOMPurify's URI checking on every tag, not just ours. Extensions may declare
143
+ // arbitrary attributes on arbitrary tags — home/docs/extensions.md's own worked
144
+ // example is an <iframe> — so an extension declaring `url` would otherwise
145
+ // inherit an exception granted because *an attachment's* url becomes an img src.
146
+ // Nothing about a third party's element supports that, and `data:text/html` in a
147
+ // navigational sink is script execution. Everything else gets the standard
148
+ // policy, which is what it would have had if we had never touched `url`.
149
+ //
150
+ // That argument is about an attribute feeding a URL sink, not about
151
+ // ADD_URI_SAFE_ATTR as such. The inert text attributes beside `url` in that list
152
+ // — `caption`, `filename` — keep the attribute-name-wide exemption, and need it:
153
+ // ordinary prose like "Q4: results" is not a URI any regex here would accept.
154
+ const URI_BEARING_ATTACHMENT_ATTRIBUTES = [ "url" ];
155
+
156
+ // DOMPurify's own IS_ALLOWED_URI scheme list, reproduced rather than narrowed, so
157
+ // `url` on a non-attachment tag is treated exactly as DOMPurify would have treated
158
+ // it. Kept as a source string so allowedUriRegexp() can widen it with an editor's
159
+ // declared schemes without re-deriving the rest of the pattern.
160
+ const BASE_URI_SCHEMES = "(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix";
161
+
162
+ // A scheme name (not a full pattern), so a caller can't inject regexp syntax.
163
+ const SCHEME_NAME = /^[a-z][a-z0-9+.-]*$/;
164
+
165
+ // The executable schemes, by DOMPurify's own IS_SCRIPT_OR_DATA definition
166
+ // (/^(?:\w+script|data):/i): javascript, vbscript, …script, and data. Widening the
167
+ // allowlist to admit one would let it survive on href/object[data], which the
168
+ // override otherwise defeats — DOMPurify does not re-block a scheme its
169
+ // ALLOWED_URI_REGEXP accepts. A caller declaring one is dropped, so it stays
170
+ // refused. This is a closed set (the schemes a browser executes), not a growing
171
+ // denylist.
172
+ const EXECUTABLE_SCHEME = /^(?:\w+script|data)$/i;
173
+
174
+ // Builds DOMPurify's default IS_ALLOWED_URI, optionally with extra schemes folded
175
+ // into the scheme alternation. Widening the recognised-safe scheme set is how a
176
+ // custom-scheme identifier (a mention's `gid://…`) passes validation without
177
+ // exempting any attribute from it. Passing no schemes reproduces DOMPurify's
178
+ // default exactly.
179
+ function allowedUriRegexp(extraSchemes = []) {
180
+ const extra = extraSchemes
181
+ .map(scheme => String(scheme).toLowerCase())
182
+ .filter(scheme => SCHEME_NAME.test(scheme) && !EXECUTABLE_SCHEME.test(scheme))
183
+ .map(scheme => scheme.replace(/[.+-]/g, "\\$&"));
184
+
185
+ const schemes = [ BASE_URI_SCHEMES, ...extra ].join("|");
186
+
187
+ return new RegExp(`^(?:(?:${schemes}):|[^a-z]|[a-z+.-]+(?:[^a-z+.:-]|$))`, "i")
188
+ }
189
+
190
+ const ALLOWED_URI = allowedUriRegexp();
191
+
192
+ // eslint-disable-next-line no-control-regex -- mirrors DOMPurify's own ATTR_WHITESPACE
193
+ const ATTR_WHITESPACE = /[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g;
194
+
195
+ // Tested against the value as given, never the whitespace-stripped copy, so a
196
+ // scheme smuggled at a nonzero offset is refused. DOMPurify draws the same line:
197
+ // its data: allowance is a prefix test on the un-stripped value, which is why
198
+ // this is a second step rather than one more scheme in ALLOWED_URI.
199
+ //
200
+ // One deliberate difference from DOMPurify's img[src] handling remains, stated
201
+ // because the earlier claim of matching it "exactly" was not true: DOMPurify
202
+ // tests for a literal lowercase `data:` and this is case-insensitive, so `DATA:`
203
+ // passes here too. That matches how browsers resolve schemes, which is what
204
+ // actually decides whether the URL loads.
205
+ const ATTACHMENT_DATA_URI = /^data:/i;
206
+
207
+ function isAttachmentTag(tag) {
208
+ return tag === Lexxy.global.get("attachmentTagName")
209
+ }
210
+
211
+ function attachmentUriFilterHook(currentNode, hookEvent) {
212
+ if (!URI_BEARING_ATTACHMENT_ATTRIBUTES.includes(hookEvent.attrName)) return
213
+
214
+ // DOMPurify keeps an empty value — its chain ends `else if (value) { return
215
+ // false } else ;` — while every alternation here needs at least one character.
216
+ if (!hookEvent.attrValue) return
217
+
218
+ const value = String(hookEvent.attrValue);
219
+
220
+ if (ALLOWED_URI.test(value.replace(ATTR_WHITESPACE, ""))) return
221
+ if (isAttachmentTag(currentNode?.nodeName?.toLowerCase()) && ATTACHMENT_DATA_URI.test(value)) return
222
+
223
+ hookEvent.keepAttr = false;
224
+ }
225
+
226
+ // Lexxy's own DOMPurify instance, deliberately not the shared default export.
227
+ //
228
+ // dompurify's default export is a singleton, and both its config and its hooks
229
+ // are global to every consumer in the bundle. That makes configuring it from an
230
+ // editor's connectedCallback actively dangerous for the host app: DOMPurify
231
+ // treats a persistent config as final, so once setConfig() has run, every
232
+ // later `sanitize(html, config)` anywhere in the app silently ignores its own
233
+ // config argument. An app sanitizing untrusted HTML with, say,
234
+ // `{ ALLOW_DATA_ATTR: false }` would keep passing that option and stop getting
235
+ // it the moment a Lexxy editor connected — with no error and no visible change
236
+ // at the call site.
237
+ //
238
+ // Calling the default export with a window returns a fresh, independent
239
+ // instance. This one carries the hooks and config below; nothing we do here can
240
+ // reach the app's instance, and nothing it does can reach ours.
241
+ //
242
+ // Under Trusted Types, every DOMPurify instance tries to create a policy named
243
+ // `dompurify` on its first sanitize, and TT rejects a duplicate name — so the
244
+ // second instance on the page gets none. That matters, and not because it throws:
245
+ // DOMPurify hands its input to DOMParser.parseFromString, which is itself a TT
246
+ // sink, but `_initDocument` swallows that throw and the innerHTML throw from its
247
+ // createDocument fallback, and `sanitize` then returns "" for the body it never
248
+ // got. An unsigned instance silently drops all content, with nothing in the host's
249
+ // error tracker to say why — and which sanitizer goes silent depends on which one
250
+ // ran first.
251
+ //
252
+ // So we create our own, under our own name, and hand it to DOMPurify rather than
253
+ // letting it try. Lazily, because resolving it at import would fire a CSP
254
+ // violation report on every page load of an app that imports Lexxy and never
255
+ // renders an editor, and at most once, because TT throws on a duplicate name.
256
+ // Guarded too, because creating a policy the CSP hasn't allowlisted throws: if
257
+ // that happens we're back to no policy, which is exactly where this stood before.
258
+ // An app enforcing `require-trusted-types-for 'script'` should add `lexxy` to its
259
+ // `trusted-types` directive.
260
+ //
261
+ // What this does NOT do is make Lexxy work under enforced Trusted Types. It
262
+ // stops *our* sanitizer from taking the host's policy name and breaking the
263
+ // host's; it does nothing about Lexxy's own unwrapped sinks, and there are
264
+ // several. `parseHtml` in helpers/html_helper.js hands a plain string to
265
+ // DOMParser.parseFromString on the initial-value path, so the editor throws
266
+ // before it finishes connecting — verified in Chromium under
267
+ // `require-trusted-types-for 'script'`, with `lexxy` allowlisted and without.
268
+ // `createElement` in the same file is a second sink in it, writing its `content`
269
+ // argument through innerHTML for the two callers that pass one — the wrapped-table
270
+ // figure and the table tools' count. `highlightElement` in
271
+ // helpers/code_highlighting_helper.js writes Prism's output the same way, and both
272
+ // it and highlightCode are exported from src/index.js — so that one throws for a
273
+ // host app calling Lexxy's highlighting API directly, outside any editor. The
274
+ // `insertAdjacentHTML` in nodes/custom_action_text_attachment_node.js and the
275
+ // `innerHTML` writes across elements/ — the toolbar, the dropdowns, the node
276
+ // delete button — are in the same position. Making the editor usable under TT is a
277
+ // separate piece of work; this is a prerequisite for it, not the whole of it.
278
+ let trustedTypesPolicyResolved = false;
279
+ let resolvedTrustedTypesPolicy = null;
280
+
281
+ function trustedTypesPolicy() {
282
+ if (!trustedTypesPolicyResolved) {
283
+ resolvedTrustedTypesPolicy = createTrustedTypesPolicy();
284
+ trustedTypesPolicyResolved = true;
285
+ }
286
+
287
+ return resolvedTrustedTypesPolicy
288
+ }
289
+
290
+ function createTrustedTypesPolicy() {
291
+ // Feature-detected below, so browsers without Trusted Types simply get no
292
+ // policy — the same path as a CSP that doesn't allowlist ours.
293
+ // eslint-disable-next-line compat/compat
294
+ const trustedTypes = window.trustedTypes;
295
+
296
+ if (typeof trustedTypes?.createPolicy !== "function") return null
297
+
298
+ try {
299
+ return trustedTypes.createPolicy("lexxy", { createHTML: (html) => html, createScriptURL: (url) => url })
300
+ } catch {
301
+ // Warned rather than swallowed, matching what DOMPurify does when its own
302
+ // policy is refused. The fallback is a silent loss of Trusted Types coverage
303
+ // otherwise, and the CSP violation report alone doesn't name us.
304
+ console.warn("TrustedTypes policy lexxy could not be created.");
305
+ return null
306
+ }
307
+ }
308
+
309
+ const DOMPurify = createDOMPurify(window);
310
+
311
+ // alt is inert on every element it can appear on, so it sits in the blanket
312
+ // list. srcset is deliberately absent — it carries URLs, so it belongs to a
313
+ // consumer that declares it.
314
+ const ALLOWED_HTML_ATTRIBUTES = [ "alt", "class", "contenteditable", "href", "src", "style", "title" ];
315
+
316
+ // width/height are scoped to img rather than allowlisted globally, because
317
+ // ALLOWED_ATTR is not per-tag: putting them there would also permit
318
+ // `<table width="100000">` and `<td height="500">` in attachment content, which
319
+ // is layout the editor previously stripped. An image needs them to hold its
320
+ // place while it loads; nothing else here does.
321
+ const DEFAULT_TAG_ATTRIBUTES = { img: [ "width", "height" ] };
46
322
 
47
323
  const ALLOWED_STYLE_PROPERTIES = [ "color", "background-color" ];
48
324
 
@@ -66,15 +342,17 @@ function styleFilterHook(_currentNode, hookEvent) {
66
342
  }
67
343
 
68
344
  DOMPurify.addHook("uponSanitizeAttribute", styleFilterHook);
345
+ DOMPurify.addHook("uponSanitizeAttribute", attachmentUriFilterHook);
69
346
 
70
347
  const FORBIDDEN_STIMULUS_ATTRIBUTES = [ "data-controller", "data-action" ];
71
348
 
72
349
  // 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.
350
+ // extension's allowedElements declares. On dompurify 3.4.13 FORBID_ATTR already
351
+ // carries that on its own: _isValidAttribute opens with it, ahead of the
352
+ // functional ADD_ATTR Lexxy builds from the public allowedElements API. So this
353
+ // hook is defence in depth rather than the barrier, and it is kept because it
354
+ // holds without reference to the config — the prohibition is a class-level one,
355
+ // and a FORBID_ATTR entry lives or dies with whatever rebuilds the config.
78
356
  function stimulusAttributeFilterHook(_currentNode, hookEvent) {
79
357
  if (FORBIDDEN_STIMULUS_ATTRIBUTES.includes(hookEvent.attrName)) {
80
358
  hookEvent.keepAttr = false;
@@ -89,29 +367,91 @@ DOMPurify.addHook("uponSanitizeElement", (node, data) => {
89
367
  }
90
368
  });
91
369
 
92
- function buildConfig(allowedElements ) {
93
- const tagAttributes = {};
370
+ // Called with no allowedElements for a sanitizer that has no allowlist to apply,
371
+ // which is not the same thing as an empty one — see EditorSanitizer's fallback.
372
+ function buildConfig(allowedElements = null) {
373
+ const config = {
374
+ ADD_URI_SAFE_ATTR: [ "caption", "filename", ...URI_BEARING_ATTACHMENT_ATTRIBUTES ],
375
+ SAFE_FOR_XML: false, // So that it does not strip attributes that contains serialized HTML (like content)
376
+ // Stimulus behavior attributes must never survive sanitization: they let stored content
377
+ // wire up arbitrary controllers/actions in the viewer's session. FORBID_ATTR wins over
378
+ // ALLOWED_ATTR/ADD_ATTR/ALLOW_DATA_ATTR in DOMPurify, so this holds even though other
379
+ // data-* attributes (data-language, data-trix-*, etc.) are otherwise allowed through.
380
+ FORBID_ATTR: [ "data-controller", "data-action" ]
381
+ };
94
382
 
383
+ // Left out rather than emptied when there is no allowlist, so DOMPurify's own
384
+ // default tag and attribute policy stands. `ALLOWED_TAGS: []` would not be a
385
+ // default, it would be a refusal: it strips every tag. An editor that declares
386
+ // an empty allowlist still gets that refusal, because it asked for it.
387
+ //
388
+ // uriSafeSchemes widens the scheme validation rather than exempting an attribute
389
+ // from it: ALLOWED_URI_REGEXP is set only when an editor declares custom schemes,
390
+ // so a value like a mention's `gid://…` passes while javascript:/data: stay
391
+ // refused on every attribute — including href and object[data]. Nothing is taken
392
+ // out of URI checking, so there is no attribute to guard against exempting.
393
+ if (allowedElements) {
394
+ const { uriSafeSchemes, ...tagPolicy } = allowlistFor(allowedElements);
395
+ if (uriSafeSchemes.length) config.ALLOWED_URI_REGEXP = allowedUriRegexp(uriSafeSchemes);
396
+ Object.assign(config, tagPolicy);
397
+ }
398
+
399
+ // Always assigned, including when we have no policy — `null` is what
400
+ // trustedTypesPolicy() returns then, and `TRUSTED_TYPES_POLICY: null` is
401
+ // DOMPurify's documented per-call opt-out: sign nothing, create nothing.
402
+ //
403
+ // Leaving the key out is a different thing entirely, and the wrong one. With no
404
+ // key DOMPurify falls through to _getDefaultTrustedTypesPolicy() and asks the
405
+ // browser for `dompurify` — the very name this exists to stop competing for — so
406
+ // an omitted key would disarm the sanitizer of a host shipping
407
+ // `trusted-types dompurify` on the one path where we couldn't get our own.
408
+ // Present-and-`undefined` lands in that same fallthrough, so it is not a
409
+ // substitute for `null` either.
410
+ config.TRUSTED_TYPES_POLICY = trustedTypesPolicy();
411
+
412
+ return config
413
+ }
414
+
415
+ function allowlistFor(allowedElements) {
416
+ // Null prototype, so a declared tag can never read through to an
417
+ // Object.prototype key: `tagAttributes["constructor"]` would answer with a
418
+ // function, and ADD_ATTR would call .includes on it.
419
+ const tagAttributes = Object.create(null);
420
+ const uriSafeSchemes = [];
421
+
422
+ // Lowercased, because DOMPurify lowercases ALLOWED_TAGS and calls ADD_ATTR
423
+ // with the lowercased tag and attribute names. Keeping the caller's casing
424
+ // makes allowedElements silently partial: `[ "IMG" ]` allows the element but
425
+ // drops the width/height below, and `[ { tag: "img", attributes: [ "GID" ] } ]`
426
+ // drops the attribute it declares.
95
427
  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
- }
428
+ const tag = String(element.tag ?? element).toLowerCase();
429
+ const attributes = (element.attributes ?? []).map(attribute => attribute.toLowerCase());
430
+
431
+ tagAttributes[tag] ||= [];
432
+ tagAttributes[tag].push(...attributes);
433
+
434
+ // A custom scheme a caller declares is folded into the editor's URI-scheme
435
+ // allowlist (ALLOWED_URI_REGEXP in buildConfig), so a value like a mention's
436
+ // `gid="gid://…"` — which DOMPurify otherwise drops as an unknown scheme, even
437
+ // once the `gid` name is allowed — passes validation. It widens the recognised
438
+ // schemes, it does not exempt an attribute: javascript:/data: stay refused
439
+ // everywhere, so there is no navigational attribute to guard. Declared per
440
+ // element for locality, but a scheme is editor-wide once allowed.
441
+ uriSafeSchemes.push(...(element.uriSafeSchemes ?? []));
442
+ }
443
+
444
+ // Only for tags the caller already permits — this widens what an allowed
445
+ // element may carry, never which elements are allowed.
446
+ for (const [ tag, attributes ] of Object.entries(DEFAULT_TAG_ATTRIBUTES)) {
447
+ if (tagAttributes[tag]) tagAttributes[tag].push(...attributes);
102
448
  }
103
449
 
104
450
  return {
105
451
  ALLOWED_TAGS: Object.keys(tagAttributes),
106
452
  ALLOWED_ATTR: ALLOWED_HTML_ATTRIBUTES,
107
453
  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" ]
454
+ uriSafeSchemes
115
455
  }
116
456
  }
117
457
 
@@ -1377,91 +1717,111 @@ class LinkDropdown extends ToolbarDropdown {
1377
1717
  }
1378
1718
  }
1379
1719
 
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
- }
1720
+ // An editor's sanitizer: the allowlist it was registered with, applied to a
1721
+ // string.
1722
+ //
1723
+ // The allowlist is per editor, and passed to each sanitize() call. Neither of
1724
+ // those is incidental. This used to be a module-level config installed with
1725
+ // DOMPurify.setConfig() on the shared singleton, which had two distinct
1726
+ // consequences:
1727
+ //
1728
+ // 1. A persistent config is final — DOMPurify ignores the per-call config once
1729
+ // one is set — so it silently disarmed the sanitizing of any host app that
1730
+ // also imports dompurify. See config/dom_purify for why we now own our
1731
+ // instance; keeping the config per-call means there is no global sanitizer
1732
+ // state left even on that instance.
1733
+ //
1734
+ // 2. One config for the whole module meant the last editor to connect decided
1735
+ // how every other editor on the page sanitized. That is not cosmetic: an
1736
+ // editor's `value` is sanitized on read, so a rich editor sharing a page with
1737
+ // a plain one would silently drop its own headings, lists and links from the
1738
+ // value it submits.
1739
+ //
1740
+ // Sanitizers are keyed by the Lexical editor, which is the identity both call
1741
+ // sites have: the element registers with it and keeps the result as
1742
+ // `this.sanitizer`, while nodes only receive it as createDOM()'s second argument
1743
+ // and resolve their sanitizer with for().
1744
+ class EditorSanitizer {
1745
+ static #instances = new WeakMap()
1746
+
1747
+ // Only reached for an editor that never registered, which is a supported case:
1748
+ // CustomActionTextAttachmentNode is exported from src/index.js, so it can be
1749
+ // registered in a Lexical editor Lexxy did not build.
1750
+ //
1751
+ // Fixed, and never reassigned. Pointing it at whichever sanitizer registered
1752
+ // most recently would make an unregistered consumer's allowlist depend on
1753
+ // which Lexxy editor happened to initialise last — the last-editor-wins bug
1754
+ // this class exists to remove, reintroduced on the one path that has no editor
1755
+ // to key on, and unobservable from the consumer's own code.
1756
+ //
1757
+ // It declares no allowlist, so DOMPurify's own default policy applies. An
1758
+ // empty allowlist would be deterministic too and strips every tag, which
1759
+ // silently drops markup a standalone consumer used to keep. It holds no
1760
+ // editor either, so nothing here pins a disconnected editor's DOM.
1761
+ //
1762
+ // A consumer wanting its own allowlist registers for it: register() needs only
1763
+ // the Lexical editor and is reachable as EditorSanitizer from src/index.js.
1764
+ //
1765
+ // Built on first use rather than as a static field initializer, which would run
1766
+ // buildConfig at import: src/index.js exports this class, so that would create
1767
+ // the Trusted Types policy — and, unallowlisted, report a CSP violation — on
1768
+ // every page load of an app that never renders an editor.
1769
+ static #fallback
1390
1770
 
1391
- function arePlainHashes(...values) {
1392
- return values.every(value => value && value.constructor == Object)
1393
- }
1771
+ static #fallbackSanitizer() {
1772
+ return this.#fallback ||= new EditorSanitizer()
1773
+ }
1394
1774
 
1395
- class Configuration {
1396
- #tree = {}
1775
+ static register(editor, allowedElements = []) {
1776
+ const sanitizer = new EditorSanitizer(this.#allowedElementsFor(editor, allowedElements));
1777
+ this.#instances.set(editor, sanitizer);
1397
1778
 
1398
- constructor(...configs) {
1399
- this.merge(...configs);
1779
+ return sanitizer
1400
1780
  }
1401
1781
 
1402
- merge(...configs) {
1403
- return this.#tree = configs.reduce(deepMerge, this.#tree)
1782
+ static for(editor) {
1783
+ return this.#instances.get(editor) ?? this.#fallbackSanitizer()
1404
1784
  }
1405
1785
 
1406
- get(path) {
1407
- const keys = path.split(".");
1408
- return keys.reduce((node, key) => node[key], this.#tree)
1786
+ // An editor can import every tag it can convert from HTML, plus whatever its
1787
+ // extensions declare. Lexical registers the conversions while building the
1788
+ // editor, so they are already in place when $initialEditorState registers us.
1789
+ static #allowedElementsFor(editor, allowedElements) {
1790
+ return this.#importableTags(editor).concat(allowedElements)
1409
1791
  }
1410
- }
1411
1792
 
1412
- function range(from, to) {
1413
- return [ ...Array(1 + to - from).keys() ].map(i => i + from)
1414
- }
1793
+ static #importableTags(editor) {
1794
+ const tags = Array.from(editor._htmlConversions.keys());
1795
+ return tags.filter(tag => !tag.startsWith("#"))
1796
+ }
1415
1797
 
1416
- const global = new Configuration({
1417
- attachmentTagName: "action-text-attachment",
1418
- attachmentContentTypeNamespace: "actiontext",
1419
- authenticatedUploads: false,
1420
- extensions: []
1421
- });
1798
+ #config
1422
1799
 
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
- }
1800
+ constructor(allowedElements = null) {
1801
+ this.#config = buildConfig(allowedElements);
1444
1802
  }
1445
- });
1446
1803
 
1447
- var Lexxy = {
1448
- global,
1449
- presets,
1450
- configure({ global: newGlobal, ...newPresets }) {
1451
- if (newGlobal) {
1452
- global.merge(newGlobal);
1804
+ // safeForXml opts into DOMPurify's mXSS-safe mode, for a caller re-inflating
1805
+ // stored content. It is off by default, and the reason is not that the default
1806
+ // hop is trustworthy — the value an editor reads back carries the attacker's
1807
+ // stored `content` verbatim. The reason is that strictness there is destructive:
1808
+ // SAFE_FOR_XML drops any attribute whose value could close a comment, a
1809
+ // serialized `content` full of Rails view annotations is exactly that, and
1810
+ // CustomActionTextAttachmentNode.importDOM returns null for an attachment with no
1811
+ // `content` — so the attachment vanishes on the next edit. Verified: strict on the
1812
+ // value hop leaves `<action-text-attachment sgid content-type>` and re-editing that
1813
+ // renders nothing.
1814
+ //
1815
+ // The strict config is this instance's own, spread rather than rebuilt, so the
1816
+ // per-editor allowlist and everything else buildConfig put there — the Trusted
1817
+ // Types policy, ADD_URI_SAFE_ATTR — survive the flip.
1818
+ sanitize(html, { safeForXml = false } = {}) {
1819
+ if (safeForXml) {
1820
+ return DOMPurify.sanitize(html, { ...this.#config, SAFE_FOR_XML: true })
1453
1821
  }
1454
- presets.merge(newPresets);
1455
- }
1456
- };
1457
-
1458
- function setSanitizerConfig(allowedTags) {
1459
- DOMPurify.clearConfig();
1460
- DOMPurify.setConfig(buildConfig(allowedTags));
1461
- }
1462
1822
 
1463
- function sanitize(html) {
1464
- return DOMPurify.sanitize(html)
1823
+ return DOMPurify.sanitize(html, this.#config)
1824
+ }
1465
1825
  }
1466
1826
 
1467
1827
  function bytesToHumanSize(bytes) {
@@ -1560,11 +1920,26 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
1560
1920
  this.plainText = plainText ?? extractPlainTextFromHtml(innerHtml);
1561
1921
  }
1562
1922
 
1563
- createDOM() {
1923
+ createDOM(_config, editor) {
1564
1924
  const figure = createElement(this.tagName, { "content-type": this.contentType, "data-lexxy-decorator": true, draggable: true });
1565
1925
  figure.dataset.lexicalNodeKey = this.__key;
1566
1926
 
1567
- figure.insertAdjacentHTML("beforeend", sanitize(this.innerHtml));
1927
+ // Resolved from the editor so this content is sanitized with its own
1928
+ // allowlist rather than whichever editor connected most recently.
1929
+ //
1930
+ // this.innerHtml is untrusted stored content being re-inflated into the editor,
1931
+ // so it goes through DOMPurify's mXSS-safe mode. Strictness is free here because
1932
+ // of where the `content` attribute that has to survive is sanitized, which is not
1933
+ // this hop: it is produced by exportDOM and by the server-side pass in
1934
+ // lib/lexxy/rich_text_area_tag.rb, and it is only ever sanitized on the lax hop
1935
+ // where an editor reads its own value back.
1936
+ //
1937
+ // The decoded inner markup can carry a `content` attribute of its own — nested
1938
+ // attachment markup does, and the attribute is allowlisted on the attachment tag
1939
+ // in extensions/attachments_extension.js — and mXSS-safe mode drops it. That loss
1940
+ // is cosmetic: it is a nested attachment's rendering inside this one, not the
1941
+ // attribute anything re-imports from.
1942
+ figure.insertAdjacentHTML("beforeend", EditorSanitizer.for(editor).sanitize(this.innerHtml, { safeForXml: true }));
1568
1943
 
1569
1944
  const deleteButton = createElement("lexxy-node-delete-button");
1570
1945
  figure.appendChild(deleteButton);
@@ -9373,7 +9748,7 @@ class LexicalEditorElement extends HTMLElement {
9373
9748
 
9374
9749
  #readSanitizedEditorValue() {
9375
9750
  return this.editor?.read(() => {
9376
- return sanitize($generateHtmlFromNodes(this.editor, null))
9751
+ return this.sanitizer.sanitize($generateHtmlFromNodes(this.editor, null))
9377
9752
  }) ?? null
9378
9753
  }
9379
9754
 
@@ -9760,16 +10135,7 @@ class LexicalEditorElement extends HTMLElement {
9760
10135
  }
9761
10136
 
9762
10137
  #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("#"))
10138
+ this.sanitizer = EditorSanitizer.register(editor, this.extensions.allowedElements);
9773
10139
  }
9774
10140
 
9775
10141
  #dispatchAttributesChange() {
@@ -11714,4 +12080,4 @@ const configure = Lexxy.configure;
11714
12080
  // Pushing elements definition to after the current call stack to allow global configuration to take place first
11715
12081
  setTimeout(defineElements, 0);
11716
12082
 
11717
- export { $createActionTextAttachmentNode, $createActionTextAttachmentUploadNode, $isActionTextAttachmentNode, $isCustomActionTextAttachmentNode, ActionTextAttachmentNode, ActionTextAttachmentUploadNode, CustomActionTextAttachmentNode, LexxyExtension as Extension, HorizontalDividerNode, NativeAdapter, REWRITE_HISTORY_COMMAND, configure };
12083
+ 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",
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"