@richscripts/richtexteditor 2.4.0 → 2.4.1

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 (58) hide show
  1. package/migration/README.md +100 -0
  2. package/migration/cli.js +294 -0
  3. package/migration/index.d.ts +18 -0
  4. package/migration/index.js +242 -0
  5. package/migration/report-schema-v2.json +127 -0
  6. package/package.json +11 -1
  7. package/richtexteditor/CHANGELOG.md +326 -1
  8. package/richtexteditor/all_plugins.js +14959 -4832
  9. package/richtexteditor/all_plugins.min.js +62 -42
  10. package/richtexteditor/plugins/accessibilitychecker.js +8 -7
  11. package/richtexteditor/plugins/aitoolkit.css +17 -8
  12. package/richtexteditor/plugins/aitoolkit.js +8 -8
  13. package/richtexteditor/plugins/all_plugins.js +14959 -4832
  14. package/richtexteditor/plugins/all_plugins.min.js +62 -42
  15. package/richtexteditor/plugins/autocorrect.js +353 -0
  16. package/richtexteditor/plugins/bookmarkcard.js +12 -1
  17. package/richtexteditor/plugins/changecase.js +238 -0
  18. package/richtexteditor/plugins/comments.js +15 -28
  19. package/richtexteditor/plugins/contentminimap.js +6 -5
  20. package/richtexteditor/plugins/crossreference.js +408 -0
  21. package/richtexteditor/plugins/documentimport.js +915 -21
  22. package/richtexteditor/plugins/documentoutline.js +5 -4
  23. package/richtexteditor/plugins/docxexport.js +960 -0
  24. package/richtexteditor/plugins/draghandle.js +309 -0
  25. package/richtexteditor/plugins/footnotes.js +398 -0
  26. package/richtexteditor/plugins/formatpainter.js +298 -0
  27. package/richtexteditor/plugins/formattingmarks.js +246 -0
  28. package/richtexteditor/plugins/html2pdf.js +108 -4
  29. package/richtexteditor/plugins/imageeditor.js +37 -7
  30. package/richtexteditor/plugins/insertgallery.js +553 -170
  31. package/richtexteditor/plugins/keyboarda11y.js +316 -0
  32. package/richtexteditor/plugins/linenumbers.js +352 -0
  33. package/richtexteditor/plugins/linkchecker.js +396 -0
  34. package/richtexteditor/plugins/mentionplugin.js +4 -3
  35. package/richtexteditor/plugins/mergefields.js +328 -0
  36. package/richtexteditor/plugins/multilevellist.js +137 -0
  37. package/richtexteditor/plugins/pagination.js +505 -0
  38. package/richtexteditor/plugins/pdfexport.js +1137 -0
  39. package/richtexteditor/plugins/permanentpen.js +211 -0
  40. package/richtexteditor/plugins/revisionhistory.js +47 -34
  41. package/richtexteditor/plugins/rtlui.js +150 -0
  42. package/richtexteditor/plugins/sanitizer.js +486 -0
  43. package/richtexteditor/plugins/slashcommand.js +142 -17
  44. package/richtexteditor/plugins/spellcheck.js +6 -3
  45. package/richtexteditor/plugins/tableofcontents.js +353 -0
  46. package/richtexteditor/plugins/tabletools.js +342 -0
  47. package/richtexteditor/plugins/textdirection.js +313 -0
  48. package/richtexteditor/plugins/trackedchanges.js +37 -6
  49. package/richtexteditor/plugins/tui.image-editor/tui-code-snippet.min.js +1 -1
  50. package/richtexteditor/plugins/tui.image-editor/tui-color-picker.min.js +1 -1
  51. package/richtexteditor/plugins/tui.image-editor/tui-image-editor.min.js +1 -1
  52. package/richtexteditor/plugins/vendor/html2pdf.bundle.min.js +3 -0
  53. package/richtexteditor/plugins/watermark.js +214 -0
  54. package/richtexteditor/plugins/wordexport.js +0 -0
  55. package/richtexteditor/plugins/yjscollab.js +49 -11
  56. package/richtexteditor/rte-config.js +44 -44
  57. package/richtexteditor/rte.js +91 -63
  58. package/richtexteditor/rte_theme_default.css +462 -384
@@ -0,0 +1,486 @@
1
+ if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
2
+
3
+ // 2026-08-02 Content sanitizer.
4
+ //
5
+ // The editor core already does most of the hard part: it strips inline event
6
+ // handlers (onerror/onload/ontoggle/onfocus) and rejects javascript: on links
7
+ // and form actions. Verified by loading a battery of payloads — none of them
8
+ // executed inside the editor.
9
+ //
10
+ // But "nothing executes in the editor" is the wrong bar. The bar is "nothing
11
+ // executes in the application that renders what the editor SAVED", because that
12
+ // is the shape every deployment has: user A writes content, it is stored, and
13
+ // user B's browser renders it. Three constructs survived the core filter into
14
+ // saved output, and one of them is live stored XSS:
15
+ //
16
+ // <iframe srcdoc="&lt;script&gt;..."> -- EXECUTES when rendered downstream
17
+ // <object data="javascript:..."> -- script-bearing URL kept verbatim
18
+ // <style>@import "javascript:..."</style>-- CSS injection / exfiltration
19
+ //
20
+ // So this filters on the way in AND on the way out. Output is the guaranteed
21
+ // path: it is what gets stored, it runs against a detached copy so it can never
22
+ // disturb the caret, and it means content that arrived before this plugin
23
+ // existed is cleaned on its next save.
24
+ //
25
+ // This is a defence in depth, not a licence to render untrusted HTML without
26
+ // server-side checks — see the note in the docs.
27
+ //
28
+ // API:
29
+ // editor.sanitizeHtml(html) -> cleaned HTML string
30
+ // editor.getSanitizerReport() -> what the last pass removed
31
+ // Config:
32
+ // config.contentSanitizer = false // disable entirely
33
+ // config.sanitizerAllowStyleTags = true // keep <style> in content
34
+ // config.sanitizerAllowIframes = false // drop <iframe> outright
35
+ // config.sanitizerAllowedIframeHosts = ["www.youtube.com"]
36
+ // config.sanitizerAllowTags = ["custom-el"]
37
+ // config.sanitizerAllowAttributes = ["my-attr"]
38
+ RTE_DefaultConfig.plugin_sanitizer = RTE_Plugin_Sanitizer;
39
+ if (typeof RTE_DefaultConfig.contentSanitizer === "undefined") RTE_DefaultConfig.contentSanitizer = true;
40
+
41
+ function RTE_Plugin_Sanitizer() {
42
+ var obj = this;
43
+ var config, editor;
44
+ var wrapped = false;
45
+ // Cumulative, not per-call. The input pass cleans the content and the
46
+ // output pass then legitimately finds nothing left to remove, so a
47
+ // last-call-wins report answers "was anything stripped?" with a confident
48
+ // and completely wrong "no".
49
+ var report = { removedTags: [], removedAttributes: [], calls: 0, lastPasses: 0 };
50
+ var liveGuards = [];
51
+
52
+ obj.PluginName = "Sanitizer";
53
+
54
+ obj.InitConfig = function (argconfig) { config = argconfig; };
55
+
56
+ obj.InitEditor = function (argeditor) {
57
+ editor = argeditor;
58
+ if (config.contentSanitizer === false) return;
59
+
60
+ editor.sanitizeHtml = function (html) { return sanitizeHtml(String(html == null ? "" : html)); };
61
+ editor.getSanitizerReport = function () { return JSON.parse(JSON.stringify(report)); };
62
+
63
+ // The core does not isolate plugin init: one throw in here and the
64
+ // editor never finishes building. A filter is defence in depth, so it
65
+ // must never be the reason the product fails to load.
66
+ function install() {
67
+ try { wrapSerializers(); wrapSetters(); } catch (e) {}
68
+ try { bindPaste(); } catch (e) {}
69
+ try { guardLiveDom(); } catch (e) {}
70
+ }
71
+ install();
72
+ // The editable is created asynchronously, and collaboration attaches
73
+ // later still — so re-install until it takes.
74
+ try { editor.attachEvent("ready", install); } catch (e) {}
75
+ setTimeout(install, 0);
76
+ setTimeout(install, 400);
77
+ };
78
+
79
+ // ------------------------------------------------------------- policy
80
+ //
81
+ // Allowlist, not blocklist. A blocklist is a promise to have thought of
82
+ // every element HTML will ever gain; an allowlist fails closed.
83
+ var ALLOWED_TAGS = {};
84
+ (
85
+ "p,div,span,br,hr,h1,h2,h3,h4,h5,h6," +
86
+ "b,strong,i,em,u,s,strike,del,ins,mark,small,sub,sup,abbr,cite,q,dfn,kbd,samp,var,time,data," +
87
+ "bdi,bdo,wbr,ruby,rt,rp," +
88
+ "ul,ol,li,dl,dt,dd," +
89
+ "table,thead,tbody,tfoot,tr,td,th,caption,colgroup,col," +
90
+ "blockquote,pre,code,figure,figcaption," +
91
+ "section,article,header,footer,main,aside,nav,address,details,summary," +
92
+ "a,img,picture,source,video,audio,track,map,area," +
93
+ // Chips, task lists and the code plugin put these in content.
94
+ "input,label,button," +
95
+ // Inline SVG (icons, diagrams). Its dangerous children are dropped below.
96
+ "svg,g,path,circle,ellipse,rect,line,polyline,polygon,text,tspan,defs,marker," +
97
+ "linearGradient,radialGradient,stop,clipPath,mask,pattern,use,symbol,title,desc"
98
+ ).split(",").forEach(function (t) { ALLOWED_TAGS[t] = true; });
99
+
100
+ // Removed WITH their contents: their text is markup, not prose.
101
+ var DROP_WHOLE = {
102
+ script: 1, noscript: 1, template: 1, object: 1, embed: 1, applet: 1,
103
+ base: 1, meta: 1, link: 1, frame: 1, frameset: 1, "foreignobject": 1,
104
+ // <title>/<desc> are legal in SVG and harmless; kept in ALLOWED_TAGS.
105
+ xml: 1, "script:": 1
106
+ };
107
+
108
+ var ALLOWED_ATTRS = {};
109
+ (
110
+ "id,class,style,title,lang,dir,translate,hidden,tabindex,role," +
111
+ // contenteditable is what keeps a footnote marker, merge field or smart
112
+ // chip ATOMIC — stripping it silently makes those editable character by
113
+ // character. It executes nothing, so there is no reason to remove it.
114
+ // sandbox is a restriction, never a capability; dropping it would make
115
+ // an embed MORE powerful.
116
+ "contenteditable,spellcheck,autocomplete,draggable,sandbox,allowfullscreen,allow,frameborder,scrolling," +
117
+ "href,src,srcset,sizes,alt,width,height,loading,decoding,referrerpolicy," +
118
+ "target,rel,download,type,value,name,placeholder,checked,disabled,readonly," +
119
+ "colspan,rowspan,headers,scope,span,align,valign,bgcolor,border,cellpadding,cellspacing," +
120
+ "start,reversed,datetime,cite,open,controls,autoplay,loop,muted,playsinline,poster,preload,kind,srclang,label," +
121
+ "usemap,ismap,coords,shape," +
122
+ // SVG presentation
123
+ "viewbox,xmlns,fill,stroke,stroke-width,stroke-linecap,stroke-linejoin,stroke-dasharray," +
124
+ "d,cx,cy,r,rx,ry,x,y,x1,y1,x2,y2,points,transform,opacity,fill-rule,clip-rule," +
125
+ "gradientunits,offset,stop-color,stop-opacity,text-anchor,font-size,font-family,font-weight," +
126
+ "preserveaspectratio,version"
127
+ ).split(",").forEach(function (a) { ALLOWED_ATTRS[a] = true; });
128
+
129
+ // Attributes whose value is a URL and must therefore be scheme-checked.
130
+ var URL_ATTRS = { href: 1, src: 1, action: 1, formaction: 1, poster: 1, cite: 1, data: 1, longdesc: 1, background: 1, ping: 1, srcset: 1 };
131
+ var SAFE_SCHEMES = /^(https?|mailto|tel|ftp|sms|callto|webcal|geo|bitcoin):/i;
132
+ var IMAGE_DATA_URI = /^data:image\/(png|jpe?g|gif|webp|bmp|x-icon|avif);base64,[a-z0-9+/=\s]+$/i;
133
+ // Deliberately NOT allowed as a data: type: image/svg+xml. An SVG data URI
134
+ // is a document, and a document can carry script.
135
+
136
+ function isSafeUrl(value, tag, attr) {
137
+ // Control characters and whitespace are stripped first: "java\\u0009script:"
138
+ // and "java\\nscript:" are both parsed as javascript: by browsers, so a
139
+ // scheme test on the raw string is trivially bypassed. Written as
140
+ // escapes — a literal control byte here would be invisible in source.
141
+ var v = String(value == null ? "" : value).replace(/[\u0000-\u0020\u007F-\u00A0]+/g, "").trim();
142
+ if (!v) return false;
143
+
144
+ // Fragments, absolute paths and query-only URLs carry no scheme.
145
+ if (/^[#?/]/.test(v)) return true;
146
+
147
+ var scheme = /^([a-z][a-z0-9+.\-]*):/i.exec(v);
148
+ if (!scheme) return true; // no scheme at all: relative
149
+
150
+ if (IMAGE_DATA_URI.test(v)) {
151
+ // A data: URI is only ever acceptable where an IMAGE is expected.
152
+ return tag === "img" || tag === "source" || attr === "poster" || attr === "srcset";
153
+ }
154
+ if (/^blob:/i.test(v)) {
155
+ return tag === "img" || tag === "video" || tag === "audio" || tag === "source";
156
+ }
157
+ return SAFE_SCHEMES.test(v);
158
+ }
159
+
160
+ // A style attribute can carry script in legacy engines and can exfiltrate
161
+ // through url(). Values are filtered rather than the attribute dropped,
162
+ // because style is how nearly all editor formatting is expressed.
163
+ function cleanStyle(value) {
164
+ // Whole DECLARATIONS are dropped, not fragments of them. Editing a value
165
+ // in place leaves debris that is both invalid CSS and misleading:
166
+ // removing "javascript:" from url(javascript:foo()) yields url(foo()),
167
+ // which then reads as a harmless relative URL and passes the very check
168
+ // that was supposed to catch it. Nesting also defeats surgical removal —
169
+ // expression(f(1)) has an inner ")" that ends the match early.
170
+ var out = [];
171
+ var decls = String(value == null ? "" : value).split(";");
172
+ for (var i = 0; i < decls.length; i++) {
173
+ var d = decls[i];
174
+ if (!d.trim()) continue;
175
+ if (/expression\s*\(|(javascript|vbscript|livescript)\s*:|-moz-binding|behaviou?r\s*:|@import|-o-link/i.test(d)) continue;
176
+ var url = /url\s*\(\s*(['"]?)([^'")]*)\1\s*\)/i.exec(d);
177
+ if (url && !isSafeUrl(url[2], "img", "src")) continue;
178
+ out.push(d.trim());
179
+ }
180
+ return out.join("; ");
181
+ }
182
+
183
+ // Embeds are a legitimate feature (autoembed), so iframes cannot simply be
184
+ // dropped. But an INJECTED iframe is still a phishing surface even though
185
+ // it cannot script the parent: it can navigate the top window, open popups
186
+ // and submit forms.
187
+ //
188
+ // With no host allowlist configured, the compromise is to keep https frames
189
+ // and force a sandbox that permits what a video embed needs and nothing
190
+ // else. Top-navigation, popups, modals and forms are all withheld. Setting
191
+ // sanitizerAllowedIframeHosts is still the stronger control.
192
+ var DEFAULT_IFRAME_SANDBOX = "allow-scripts allow-same-origin allow-presentation";
193
+
194
+ function iframeAllowed(el) {
195
+ if (config.sanitizerAllowIframes === false) return false;
196
+ var src = el.getAttribute("src") || "";
197
+ if (!/^https?:/i.test(src)) return false;
198
+ var hosts = config.sanitizerAllowedIframeHosts;
199
+ if (!hosts || !hosts.length) {
200
+ if (!el.hasAttribute("sandbox")) el.setAttribute("sandbox", DEFAULT_IFRAME_SANDBOX);
201
+ return true;
202
+ }
203
+ try {
204
+ var host = new URL(src).hostname.toLowerCase();
205
+ for (var i = 0; i < hosts.length; i++) {
206
+ var h = String(hosts[i]).toLowerCase();
207
+ if (host === h || host.slice(-(h.length + 1)) === "." + h) return true;
208
+ }
209
+ } catch (e) {}
210
+ return false;
211
+ }
212
+
213
+ // --------------------------------------------------------------- engine
214
+ function sanitizeNode(root, report) {
215
+ var allowExtra = {};
216
+ (config.sanitizerAllowTags || []).forEach(function (t) { allowExtra[String(t).toLowerCase()] = true; });
217
+ var attrExtra = {};
218
+ (config.sanitizerAllowAttributes || []).forEach(function (a) { attrExtra[String(a).toLowerCase()] = true; });
219
+ var allowStyleTag = config.sanitizerAllowStyleTags === true;
220
+
221
+ // Collect first: removing nodes while walking a live list skips siblings.
222
+ var all = [];
223
+ (function collect(n) {
224
+ for (var i = 0; i < n.childNodes.length; i++) {
225
+ var c = n.childNodes[i];
226
+ if (c.nodeType === 1) { all.push(c); collect(c); }
227
+ else if (c.nodeType === 8) all.push(c); // comments can hide markup
228
+ }
229
+ })(root);
230
+
231
+ for (var i = 0; i < all.length; i++) {
232
+ var el = all[i];
233
+ if (!el.parentNode) continue; // already removed with an ancestor
234
+
235
+ if (el.nodeType === 8) { el.parentNode.removeChild(el); continue; }
236
+
237
+ var tag = (el.localName || el.nodeName || "").toLowerCase();
238
+
239
+ if (tag === "style") {
240
+ if (!allowStyleTag) { note(report.removedTags, "style"); el.parentNode.removeChild(el); continue; }
241
+ } else if (tag === "iframe") {
242
+ // srcdoc is an inline document and is never needed for an embed.
243
+ // This is the construct that actually executed downstream.
244
+ if (el.hasAttribute("srcdoc")) { note(report.removedAttributes, "iframe[srcdoc]"); el.removeAttribute("srcdoc"); }
245
+ if (!iframeAllowed(el)) { note(report.removedTags, "iframe"); el.parentNode.removeChild(el); continue; }
246
+ } else if (DROP_WHOLE[tag]) {
247
+ note(report.removedTags, tag);
248
+ el.parentNode.removeChild(el);
249
+ continue;
250
+ } else if (!ALLOWED_TAGS[tag] && !allowExtra[tag]) {
251
+ // Unknown element: unwrap rather than delete, so the words a
252
+ // user typed inside a stray tag are not silently lost.
253
+ note(report.removedTags, tag);
254
+ unwrap(el);
255
+ continue;
256
+ }
257
+
258
+ var attrs = [];
259
+ for (var a = 0; a < el.attributes.length; a++) attrs.push(el.attributes[a]);
260
+ for (var k = 0; k < attrs.length; k++) {
261
+ var name = attrs[k].name.toLowerCase();
262
+ var value = attrs[k].value;
263
+
264
+ // Any event handler, including ones invented after this ships.
265
+ if (name.indexOf("on") === 0) { note(report.removedAttributes, name); el.removeAttribute(attrs[k].name); continue; }
266
+ // Namespaced links are a classic bypass: xlink:href on <use>.
267
+ if (name === "xlink:href" || name === "xmlns:xlink") {
268
+ if (!isSafeUrl(value, tag, name)) { note(report.removedAttributes, name); el.removeAttribute(attrs[k].name); }
269
+ continue;
270
+ }
271
+ if (name === "srcdoc") { note(report.removedAttributes, "srcdoc"); el.removeAttribute(attrs[k].name); continue; }
272
+ if (name === "style") { el.setAttribute("style", cleanStyle(value)); continue; }
273
+ if (name.indexOf("data-") === 0 || name.indexOf("aria-") === 0) continue;
274
+
275
+ if (URL_ATTRS[name]) {
276
+ if (!isSafeUrl(value, tag, name)) { note(report.removedAttributes, tag + "[" + name + "]"); el.removeAttribute(attrs[k].name); }
277
+ continue;
278
+ }
279
+ if (!ALLOWED_ATTRS[name] && !attrExtra[name]) {
280
+ note(report.removedAttributes, name);
281
+ el.removeAttribute(attrs[k].name);
282
+ }
283
+ }
284
+
285
+ // A link that opens a new tab without rel="noopener" hands the
286
+ // opener window to the destination.
287
+ if (tag === "a" && (el.getAttribute("target") || "").toLowerCase() === "_blank") {
288
+ var rel = (el.getAttribute("rel") || "").toLowerCase();
289
+ if (rel.indexOf("noopener") < 0) el.setAttribute("rel", (rel ? rel + " " : "") + "noopener noreferrer");
290
+ }
291
+ }
292
+ return root;
293
+ }
294
+
295
+ function unwrap(el) {
296
+ var parent = el.parentNode;
297
+ while (el.firstChild) parent.insertBefore(el.firstChild, el);
298
+ parent.removeChild(el);
299
+ }
300
+ function note(list, what) { if (list.indexOf(what) < 0) list.push(what); }
301
+
302
+ function sanitizeHtml(html) {
303
+ var out = html;
304
+ report.calls++;
305
+ // Parsing into an inert document means nothing loads or executes while
306
+ // we inspect it -- innerHTML on a live div would fire <img onerror>
307
+ // before the first line of this ran.
308
+ for (var pass = 0; pass < 3; pass++) {
309
+ var doc = new DOMParser().parseFromString("<body>" + out + "</body>", "text/html");
310
+ sanitizeNode(doc.body, report);
311
+ var next = doc.body.innerHTML;
312
+ report.lastPasses = pass + 1;
313
+ // Re-parsing can RESURRECT markup: the classic mutation-XSS trick is
314
+ // content that is inert in one parse and dangerous once the
315
+ // serializer has rewritten it. Loop until it stops changing.
316
+ if (next === out) break;
317
+ out = next;
318
+ }
319
+ return out;
320
+ }
321
+
322
+ // ------------------------------------------------------------- plumbing
323
+ function wrapSerializers() {
324
+ if (wrapped) return;
325
+ var names = ["getHTMLCode", "getHTMLContent"];
326
+ var did = false;
327
+ for (var i = 0; i < names.length; i++) {
328
+ (function (name) {
329
+ var orig = editor[name];
330
+ if (typeof orig !== "function" || orig.__rteSanitized) return;
331
+ var w = function () {
332
+ var html = orig.apply(editor, arguments);
333
+ if (typeof html !== "string") return html;
334
+ return sanitizeHtml(html);
335
+ };
336
+ w.__rteSanitized = true;
337
+ editor[name] = w;
338
+ did = true;
339
+ })(names[i]);
340
+ }
341
+ if (did) wrapped = true;
342
+ }
343
+
344
+ function wrapSetters() {
345
+ var names = ["setHTMLCode", "setHTMLContent"];
346
+ for (var i = 0; i < names.length; i++) {
347
+ (function (name) {
348
+ var orig = editor[name];
349
+ if (typeof orig !== "function" || orig.__rteSanitizedIn) return;
350
+ var w = function (html) {
351
+ var args = [].slice.call(arguments);
352
+ if (typeof html === "string") args[0] = sanitizeHtml(html);
353
+ return orig.apply(editor, args);
354
+ };
355
+ w.__rteSanitizedIn = true;
356
+ editor[name] = w;
357
+ })(names[i]);
358
+ }
359
+ }
360
+
361
+ // Paste is the other way hostile markup arrives — and cleaning up AFTERWARDS
362
+ // is not good enough, which measurement made obvious: pasting
363
+ // <iframe srcdoc="<script>…"> executed 70ms after the paste, long before a
364
+ // post-paste pass could run. The saved document was clean, so there was no
365
+ // stored XSS, but script had already run in the editing session under the
366
+ // host page's origin. Post-hoc cleaning cannot prevent execution; only
367
+ // keeping the markup out of the live DOM can.
368
+ //
369
+ // The editor has its own paste pipeline (pasteMode, Word cleanup), and
370
+ // replacing it wholesale would throw that away. So this intercepts ONLY
371
+ // clipboard HTML carrying something that executes on insertion. A Word
372
+ // paste — heavy with <style> and mso- rules but no handlers — is left
373
+ // entirely alone and keeps its normal cleanup path.
374
+ function executesOnInsert(html) {
375
+ return /\son[a-z]+\s*=/i.test(html) // any inline handler
376
+ || /<\s*(script|iframe|object|embed|base)\b/i.test(html)
377
+ || /srcdoc\s*=/i.test(html) // a document inside an attribute
378
+ || /(javascript|vbscript)\s*:/i.test(html)
379
+ || /data:\s*text\/html/i.test(html);
380
+ // <style> is deliberately NOT here: it cannot execute script, and
381
+ // listing it would intercept every paste from Word.
382
+ }
383
+
384
+ // Content can enter the editable without passing through setHTMLCode or
385
+ // paste at all. The case that matters is real-time collaboration: a remote
386
+ // peer's edits are applied straight to the DOM by the sync engine, so a
387
+ // malicious collaborator could push <iframe srcdoc> into the shared
388
+ // document and execute script in every other participant's browser.
389
+ // Measured: the payload ran locally AND replicated to the peer with its
390
+ // onerror attribute intact.
391
+ //
392
+ // A MutationObserver is the only place to catch every arrival path — sync,
393
+ // drop, a third-party plugin, host-page code. It is honest to say this
394
+ // narrows the window rather than closing it completely: the observer runs
395
+ // as a microtask, so it beats anything that executes on a load or error
396
+ // event (which is how srcdoc and onerror fire), but it cannot pre-empt
397
+ // something that executes synchronously during insertion.
398
+ //
399
+ // Cost is kept near zero for the common case: text-node insertions from
400
+ // typing are rejected on the first check and never walk a subtree.
401
+ var DANGER_TAGS = /^(script|iframe|object|embed|base|meta|link|form)$/;
402
+
403
+ function hasExecutableAttr(el) {
404
+ if (!el.attributes) return false;
405
+ for (var i = 0; i < el.attributes.length; i++) {
406
+ var n = el.attributes[i].name.toLowerCase();
407
+ if (n.indexOf("on") === 0 || n === "srcdoc") return true;
408
+ if ((n === "href" || n === "src" || n === "data" || n === "action" || n === "xlink:href") &&
409
+ /(javascript|vbscript)\s*:|data:\s*text\/html/i.test(el.attributes[i].value || "")) return true;
410
+ }
411
+ return false;
412
+ }
413
+
414
+ function looksDangerous(el) {
415
+ if (DANGER_TAGS.test(el.localName || "")) return true;
416
+ if (hasExecutableAttr(el)) return true;
417
+ var kids = el.getElementsByTagName ? el.getElementsByTagName("*") : [];
418
+ for (var i = 0; i < kids.length; i++) {
419
+ if (DANGER_TAGS.test(kids[i].localName || "") || hasExecutableAttr(kids[i])) return true;
420
+ }
421
+ return false;
422
+ }
423
+
424
+ function guardLiveDom() {
425
+ if (config.sanitizerGuardLiveDom === false) return;
426
+ var ed = null;
427
+ try { ed = editor.getEditable(); } catch (e) { return; }
428
+ if (!ed || ed.__rteLiveGuard) return;
429
+ ed.__rteLiveGuard = true;
430
+
431
+ var mo = new MutationObserver(function (records) {
432
+ var suspect = false;
433
+ for (var i = 0; i < records.length && !suspect; i++) {
434
+ var rec = records[i];
435
+ if (rec.type === "attributes") {
436
+ var n = (rec.attributeName || "").toLowerCase();
437
+ if (n.indexOf("on") === 0 || n === "srcdoc" || hasExecutableAttr(rec.target)) suspect = true;
438
+ continue;
439
+ }
440
+ for (var a = 0; a < rec.addedNodes.length; a++) {
441
+ var node = rec.addedNodes[a];
442
+ if (node.nodeType !== 1) continue; // typing inserts text: free
443
+ if (looksDangerous(node)) { suspect = true; break; }
444
+ }
445
+ }
446
+ if (!suspect) return;
447
+ try { sanitizeNode(ed, report); } catch (e) {}
448
+ });
449
+ mo.observe(ed, { childList: true, subtree: true, attributes: true });
450
+ liveGuards.push(mo);
451
+ }
452
+
453
+ function bindPaste() {
454
+ var ed = null;
455
+ try { ed = editor.getEditable(); } catch (e) { return; }
456
+ if (!ed || ed.__rteSanitizePaste) return;
457
+ ed.__rteSanitizePaste = true;
458
+
459
+ ed.addEventListener("paste", function (e) {
460
+ var dt = e.clipboardData || window.clipboardData;
461
+ if (!dt) return;
462
+ var html = "";
463
+ try { html = dt.getData("text/html") || ""; } catch (x) { return; }
464
+ if (!html || !executesOnInsert(html)) return; // normal paste: untouched
465
+
466
+ // Dangerous: it must never reach the live DOM at all.
467
+ e.preventDefault();
468
+ e.stopImmediatePropagation();
469
+ var clean = sanitizeHtml(html);
470
+ try { editor.insertHTML(clean); }
471
+ catch (x) { try { editor.insertText(dt.getData("text/plain") || ""); } catch (y) {} }
472
+ }, true);
473
+
474
+ // Defence in depth for anything that still lands another way (drop, a
475
+ // path this plugin does not see). Two passes because a single
476
+ // setTimeout(0) can run BEFORE the browser has finished inserting —
477
+ // measured, and the reason the first version cleaned nothing.
478
+ function sweep() {
479
+ var run = function () { try { sanitizeNode(ed, report); } catch (e) {} };
480
+ setTimeout(run, 0);
481
+ setTimeout(run, 250);
482
+ }
483
+ ed.addEventListener("paste", sweep, false);
484
+ ed.addEventListener("drop", sweep, true);
485
+ }
486
+ }