@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.
- package/dist/lexxy.esm.js +472 -106
- 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,283 @@ 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. 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.
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
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
|
-
|
|
1392
|
-
|
|
1393
|
-
}
|
|
1771
|
+
static #fallbackSanitizer() {
|
|
1772
|
+
return this.#fallback ||= new EditorSanitizer()
|
|
1773
|
+
}
|
|
1394
1774
|
|
|
1395
|
-
|
|
1396
|
-
|
|
1775
|
+
static register(editor, allowedElements = []) {
|
|
1776
|
+
const sanitizer = new EditorSanitizer(this.#allowedElementsFor(editor, allowedElements));
|
|
1777
|
+
this.#instances.set(editor, sanitizer);
|
|
1397
1778
|
|
|
1398
|
-
|
|
1399
|
-
this.merge(...configs);
|
|
1779
|
+
return sanitizer
|
|
1400
1780
|
}
|
|
1401
1781
|
|
|
1402
|
-
|
|
1403
|
-
return this.#
|
|
1782
|
+
static for(editor) {
|
|
1783
|
+
return this.#instances.get(editor) ?? this.#fallbackSanitizer()
|
|
1404
1784
|
}
|
|
1405
1785
|
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
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
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1793
|
+
static #importableTags(editor) {
|
|
1794
|
+
const tags = Array.from(editor._htmlConversions.keys());
|
|
1795
|
+
return tags.filter(tag => !tag.startsWith("#"))
|
|
1796
|
+
}
|
|
1415
1797
|
|
|
1416
|
-
|
|
1417
|
-
attachmentTagName: "action-text-attachment",
|
|
1418
|
-
attachmentContentTypeNamespace: "actiontext",
|
|
1419
|
-
authenticatedUploads: false,
|
|
1420
|
-
extensions: []
|
|
1421
|
-
});
|
|
1798
|
+
#config
|
|
1422
1799
|
|
|
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
|
-
}
|
|
1800
|
+
constructor(allowedElements = null) {
|
|
1801
|
+
this.#config = buildConfig(allowedElements);
|
|
1444
1802
|
}
|
|
1445
|
-
});
|
|
1446
1803
|
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
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
|
-
|
|
1464
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
73
|
+
"dompurify": "^3.4.13",
|
|
73
74
|
"lexical": "^0.44.0",
|
|
74
75
|
"marked": "^16.4.1",
|
|
75
76
|
"prismjs": "^1.30.0"
|