@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.
- package/dist/lexxy.esm.js +419 -107
- 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
|
|
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
|
-
|
|
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.
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
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
|
-
|
|
1392
|
-
|
|
1393
|
-
}
|
|
1717
|
+
static #fallbackSanitizer() {
|
|
1718
|
+
return this.#fallback ||= new EditorSanitizer()
|
|
1719
|
+
}
|
|
1394
1720
|
|
|
1395
|
-
|
|
1396
|
-
|
|
1721
|
+
static register(editor, allowedElements = []) {
|
|
1722
|
+
const sanitizer = new EditorSanitizer(this.#allowedElementsFor(editor, allowedElements));
|
|
1723
|
+
this.#instances.set(editor, sanitizer);
|
|
1397
1724
|
|
|
1398
|
-
|
|
1399
|
-
this.merge(...configs);
|
|
1725
|
+
return sanitizer
|
|
1400
1726
|
}
|
|
1401
1727
|
|
|
1402
|
-
|
|
1403
|
-
return this.#
|
|
1728
|
+
static for(editor) {
|
|
1729
|
+
return this.#instances.get(editor) ?? this.#fallbackSanitizer()
|
|
1404
1730
|
}
|
|
1405
1731
|
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
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
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1739
|
+
static #importableTags(editor) {
|
|
1740
|
+
const tags = Array.from(editor._htmlConversions.keys());
|
|
1741
|
+
return tags.filter(tag => !tag.startsWith("#"))
|
|
1742
|
+
}
|
|
1415
1743
|
|
|
1416
|
-
|
|
1417
|
-
attachmentTagName: "action-text-attachment",
|
|
1418
|
-
attachmentContentTypeNamespace: "actiontext",
|
|
1419
|
-
authenticatedUploads: false,
|
|
1420
|
-
extensions: []
|
|
1421
|
-
});
|
|
1744
|
+
#config
|
|
1422
1745
|
|
|
1423
|
-
|
|
1424
|
-
|
|
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
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
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
|
-
|
|
1464
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
73
|
+
"dompurify": "^3.4.13",
|
|
73
74
|
"lexical": "^0.44.0",
|
|
74
75
|
"marked": "^16.4.1",
|
|
75
76
|
"prismjs": "^1.30.0"
|