@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.
- package/migration/README.md +100 -0
- package/migration/cli.js +294 -0
- package/migration/index.d.ts +18 -0
- package/migration/index.js +242 -0
- package/migration/report-schema-v2.json +127 -0
- package/package.json +11 -1
- package/richtexteditor/CHANGELOG.md +326 -1
- package/richtexteditor/all_plugins.js +14959 -4832
- package/richtexteditor/all_plugins.min.js +62 -42
- package/richtexteditor/plugins/accessibilitychecker.js +8 -7
- package/richtexteditor/plugins/aitoolkit.css +17 -8
- package/richtexteditor/plugins/aitoolkit.js +8 -8
- package/richtexteditor/plugins/all_plugins.js +14959 -4832
- package/richtexteditor/plugins/all_plugins.min.js +62 -42
- package/richtexteditor/plugins/autocorrect.js +353 -0
- package/richtexteditor/plugins/bookmarkcard.js +12 -1
- package/richtexteditor/plugins/changecase.js +238 -0
- package/richtexteditor/plugins/comments.js +15 -28
- package/richtexteditor/plugins/contentminimap.js +6 -5
- package/richtexteditor/plugins/crossreference.js +408 -0
- package/richtexteditor/plugins/documentimport.js +915 -21
- package/richtexteditor/plugins/documentoutline.js +5 -4
- package/richtexteditor/plugins/docxexport.js +960 -0
- package/richtexteditor/plugins/draghandle.js +309 -0
- package/richtexteditor/plugins/footnotes.js +398 -0
- package/richtexteditor/plugins/formatpainter.js +298 -0
- package/richtexteditor/plugins/formattingmarks.js +246 -0
- package/richtexteditor/plugins/html2pdf.js +108 -4
- package/richtexteditor/plugins/imageeditor.js +37 -7
- package/richtexteditor/plugins/insertgallery.js +553 -170
- package/richtexteditor/plugins/keyboarda11y.js +316 -0
- package/richtexteditor/plugins/linenumbers.js +352 -0
- package/richtexteditor/plugins/linkchecker.js +396 -0
- package/richtexteditor/plugins/mentionplugin.js +4 -3
- package/richtexteditor/plugins/mergefields.js +328 -0
- package/richtexteditor/plugins/multilevellist.js +137 -0
- package/richtexteditor/plugins/pagination.js +505 -0
- package/richtexteditor/plugins/pdfexport.js +1137 -0
- package/richtexteditor/plugins/permanentpen.js +211 -0
- package/richtexteditor/plugins/revisionhistory.js +47 -34
- package/richtexteditor/plugins/rtlui.js +150 -0
- package/richtexteditor/plugins/sanitizer.js +486 -0
- package/richtexteditor/plugins/slashcommand.js +142 -17
- package/richtexteditor/plugins/spellcheck.js +6 -3
- package/richtexteditor/plugins/tableofcontents.js +353 -0
- package/richtexteditor/plugins/tabletools.js +342 -0
- package/richtexteditor/plugins/textdirection.js +313 -0
- package/richtexteditor/plugins/trackedchanges.js +37 -6
- package/richtexteditor/plugins/tui.image-editor/tui-code-snippet.min.js +1 -1
- package/richtexteditor/plugins/tui.image-editor/tui-color-picker.min.js +1 -1
- package/richtexteditor/plugins/tui.image-editor/tui-image-editor.min.js +1 -1
- package/richtexteditor/plugins/vendor/html2pdf.bundle.min.js +3 -0
- package/richtexteditor/plugins/watermark.js +214 -0
- package/richtexteditor/plugins/wordexport.js +0 -0
- package/richtexteditor/plugins/yjscollab.js +49 -11
- package/richtexteditor/rte-config.js +44 -44
- package/richtexteditor/rte.js +91 -63
- 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="<script>..."> -- 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
|
+
}
|