minisiwyg-editor 0.1.0
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/LICENSE +21 -0
- package/README.md +271 -0
- package/dist/defaults.d.ts +2 -0
- package/dist/editor.d.ts +11 -0
- package/dist/index.cjs +6 -0
- package/dist/index.cjs.map +7 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +7 -0
- package/dist/policy.cjs +2 -0
- package/dist/policy.cjs.map +7 -0
- package/dist/policy.d.ts +16 -0
- package/dist/policy.js +2 -0
- package/dist/policy.js.map +7 -0
- package/dist/sanitize.cjs +2 -0
- package/dist/sanitize.cjs.map +7 -0
- package/dist/sanitize.d.ts +16 -0
- package/dist/sanitize.js +2 -0
- package/dist/sanitize.js.map +7 -0
- package/dist/shared.d.ts +16 -0
- package/dist/toolbar.cjs +2 -0
- package/dist/toolbar.cjs.map +7 -0
- package/dist/toolbar.d.ts +10 -0
- package/dist/toolbar.js +2 -0
- package/dist/toolbar.js.map +7 -0
- package/dist/types.d.ts +37 -0
- package/package.json +75 -0
- package/src/defaults.ts +32 -0
- package/src/editor.ts +376 -0
- package/src/index.ts +13 -0
- package/src/policy.ts +226 -0
- package/src/sanitize.ts +169 -0
- package/src/shared.ts +44 -0
- package/src/toolbar.css +43 -0
- package/src/toolbar.ts +159 -0
- package/src/types.ts +41 -0
package/src/defaults.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { SanitizePolicy } from './types';
|
|
2
|
+
|
|
3
|
+
const policy: SanitizePolicy = {
|
|
4
|
+
tags: {
|
|
5
|
+
p: [],
|
|
6
|
+
br: [],
|
|
7
|
+
strong: [],
|
|
8
|
+
em: [],
|
|
9
|
+
a: ['href', 'title', 'target'],
|
|
10
|
+
h1: [],
|
|
11
|
+
h2: [],
|
|
12
|
+
h3: [],
|
|
13
|
+
ul: [],
|
|
14
|
+
ol: [],
|
|
15
|
+
li: [],
|
|
16
|
+
blockquote: [],
|
|
17
|
+
pre: [],
|
|
18
|
+
code: [],
|
|
19
|
+
},
|
|
20
|
+
strip: true,
|
|
21
|
+
maxDepth: 10,
|
|
22
|
+
maxLength: 100_000,
|
|
23
|
+
protocols: ['https', 'http', 'mailto'],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Deep freeze to prevent mutation of security-critical defaults
|
|
27
|
+
Object.freeze(policy);
|
|
28
|
+
Object.freeze(policy.protocols);
|
|
29
|
+
for (const attrs of Object.values(policy.tags)) Object.freeze(attrs);
|
|
30
|
+
Object.freeze(policy.tags);
|
|
31
|
+
|
|
32
|
+
export const DEFAULT_POLICY: Readonly<SanitizePolicy> = policy;
|
package/src/editor.ts
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import type { SanitizePolicy, EditorOptions, Editor } from './types';
|
|
2
|
+
import { DEFAULT_POLICY } from './defaults';
|
|
3
|
+
import { sanitizeToFragment } from './sanitize';
|
|
4
|
+
import { createPolicyEnforcer, type PolicyEnforcer } from './policy';
|
|
5
|
+
import { isProtocolAllowed } from './shared';
|
|
6
|
+
|
|
7
|
+
export type { Editor, EditorOptions } from './types';
|
|
8
|
+
export { DEFAULT_POLICY } from './defaults';
|
|
9
|
+
|
|
10
|
+
type EditorEvent = 'change' | 'paste' | 'overflow' | 'error';
|
|
11
|
+
type EventHandler = (...args: unknown[]) => void;
|
|
12
|
+
|
|
13
|
+
const SUPPORTED_COMMANDS = new Set([
|
|
14
|
+
'bold',
|
|
15
|
+
'italic',
|
|
16
|
+
'heading',
|
|
17
|
+
'blockquote',
|
|
18
|
+
'unorderedList',
|
|
19
|
+
'orderedList',
|
|
20
|
+
'link',
|
|
21
|
+
'unlink',
|
|
22
|
+
'codeBlock',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create a contentEditable-based editor with built-in sanitization.
|
|
27
|
+
*
|
|
28
|
+
* The paste handler is the primary security boundary — it sanitizes HTML
|
|
29
|
+
* before insertion via Selection/Range API. The MutationObserver-based
|
|
30
|
+
* policy enforcer provides defense-in-depth.
|
|
31
|
+
*/
|
|
32
|
+
export function createEditor(
|
|
33
|
+
element: HTMLElement,
|
|
34
|
+
options?: EditorOptions,
|
|
35
|
+
): Editor {
|
|
36
|
+
if (!element) {
|
|
37
|
+
throw new TypeError('createEditor requires an HTMLElement');
|
|
38
|
+
}
|
|
39
|
+
if (!element.ownerDocument || !element.parentNode) {
|
|
40
|
+
throw new TypeError('createEditor requires an element attached to the DOM');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const src = options?.policy ?? DEFAULT_POLICY;
|
|
44
|
+
const policy: SanitizePolicy = {
|
|
45
|
+
tags: Object.fromEntries(
|
|
46
|
+
Object.entries(src.tags).map(([k, v]) => [k, [...v]]),
|
|
47
|
+
),
|
|
48
|
+
strip: src.strip,
|
|
49
|
+
maxDepth: src.maxDepth,
|
|
50
|
+
maxLength: src.maxLength,
|
|
51
|
+
protocols: [...src.protocols],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const handlers: Record<string, EventHandler[]> = {};
|
|
55
|
+
const doc = element.ownerDocument;
|
|
56
|
+
|
|
57
|
+
function emit(event: EditorEvent, ...args: unknown[]): void {
|
|
58
|
+
for (const handler of handlers[event] ?? []) {
|
|
59
|
+
handler(...args);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Set up contentEditable
|
|
64
|
+
element.contentEditable = 'true';
|
|
65
|
+
|
|
66
|
+
// Attach policy enforcer (MutationObserver defense-in-depth)
|
|
67
|
+
const enforcer: PolicyEnforcer = createPolicyEnforcer(element, policy);
|
|
68
|
+
enforcer.on('error', (err) => emit('error', err));
|
|
69
|
+
|
|
70
|
+
// Paste handler — the primary security boundary
|
|
71
|
+
function onPaste(e: ClipboardEvent): void {
|
|
72
|
+
e.preventDefault();
|
|
73
|
+
|
|
74
|
+
const clipboard = e.clipboardData;
|
|
75
|
+
if (!clipboard) return;
|
|
76
|
+
|
|
77
|
+
// Inside code block: paste as plain text only
|
|
78
|
+
const sel = doc.getSelection();
|
|
79
|
+
if (sel && sel.rangeCount > 0 && sel.anchorNode) {
|
|
80
|
+
const pre = findAncestor(sel.anchorNode, 'PRE');
|
|
81
|
+
if (pre) {
|
|
82
|
+
const text = clipboard.getData('text/plain');
|
|
83
|
+
if (!text) return;
|
|
84
|
+
if (policy.maxLength > 0) {
|
|
85
|
+
const currentLen = element.textContent?.length ?? 0;
|
|
86
|
+
if (currentLen + text.length > policy.maxLength) {
|
|
87
|
+
emit('overflow', policy.maxLength);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const range = sel.getRangeAt(0);
|
|
91
|
+
range.deleteContents();
|
|
92
|
+
const textNode = doc.createTextNode(text);
|
|
93
|
+
range.insertNode(textNode);
|
|
94
|
+
range.setStartAfter(textNode);
|
|
95
|
+
range.collapse(true);
|
|
96
|
+
sel.removeAllRanges();
|
|
97
|
+
sel.addRange(range);
|
|
98
|
+
emit('paste', element.innerHTML);
|
|
99
|
+
emit('change', element.innerHTML);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Prefer HTML, fall back to plain text
|
|
105
|
+
let html = clipboard.getData('text/html');
|
|
106
|
+
if (!html) {
|
|
107
|
+
const text = clipboard.getData('text/plain');
|
|
108
|
+
if (!text) return;
|
|
109
|
+
// Escape plain text and convert newlines to <br>
|
|
110
|
+
html = text
|
|
111
|
+
.replace(/&/g, '&')
|
|
112
|
+
.replace(/</g, '<')
|
|
113
|
+
.replace(/>/g, '>')
|
|
114
|
+
.replace(/"/g, '"')
|
|
115
|
+
.replace(/'/g, ''')
|
|
116
|
+
.replace(/\n/g, '<br>');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Sanitize through policy — returns DocumentFragment directly
|
|
120
|
+
// to avoid the serialize→reparse mXSS vector
|
|
121
|
+
const fragment = sanitizeToFragment(html, policy);
|
|
122
|
+
|
|
123
|
+
// Insert via Selection/Range API (NOT execCommand('insertHTML'))
|
|
124
|
+
const selection = doc.getSelection();
|
|
125
|
+
if (!selection || selection.rangeCount === 0) return;
|
|
126
|
+
|
|
127
|
+
const range = selection.getRangeAt(0);
|
|
128
|
+
range.deleteContents();
|
|
129
|
+
|
|
130
|
+
// Check overflow using text content length
|
|
131
|
+
if (policy.maxLength > 0) {
|
|
132
|
+
const pasteTextLen = fragment.textContent?.length ?? 0;
|
|
133
|
+
const currentLen = element.textContent?.length ?? 0;
|
|
134
|
+
if (currentLen + pasteTextLen > policy.maxLength) {
|
|
135
|
+
emit('overflow', policy.maxLength);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Remember last inserted node for cursor positioning
|
|
140
|
+
let lastNode: Node | null = fragment.lastChild;
|
|
141
|
+
range.insertNode(fragment);
|
|
142
|
+
|
|
143
|
+
// Move cursor after inserted content
|
|
144
|
+
if (lastNode) {
|
|
145
|
+
const newRange = doc.createRange();
|
|
146
|
+
newRange.setStartAfter(lastNode);
|
|
147
|
+
newRange.collapse(true);
|
|
148
|
+
selection.removeAllRanges();
|
|
149
|
+
selection.addRange(newRange);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
emit('paste', element.innerHTML);
|
|
153
|
+
emit('change', element.innerHTML);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Input handler for change events
|
|
157
|
+
function onInput(): void {
|
|
158
|
+
emit('change', element.innerHTML);
|
|
159
|
+
options?.onChange?.(element.innerHTML);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Keydown handler for code block behavior
|
|
163
|
+
function onKeydown(e: KeyboardEvent): void {
|
|
164
|
+
const sel = doc.getSelection();
|
|
165
|
+
if (!sel || sel.rangeCount === 0) return;
|
|
166
|
+
const anchor = sel.anchorNode;
|
|
167
|
+
if (!anchor) return;
|
|
168
|
+
|
|
169
|
+
const pre = findAncestor(anchor, 'PRE');
|
|
170
|
+
|
|
171
|
+
if (e.key === 'Enter' && pre) {
|
|
172
|
+
// Insert newline instead of new paragraph
|
|
173
|
+
e.preventDefault();
|
|
174
|
+
const range = sel.getRangeAt(0);
|
|
175
|
+
range.deleteContents();
|
|
176
|
+
const textNode = doc.createTextNode('\n');
|
|
177
|
+
range.insertNode(textNode);
|
|
178
|
+
range.setStartAfter(textNode);
|
|
179
|
+
range.collapse(true);
|
|
180
|
+
sel.removeAllRanges();
|
|
181
|
+
sel.addRange(range);
|
|
182
|
+
emit('change', element.innerHTML);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (e.key === 'Backspace' && pre) {
|
|
186
|
+
// At start of empty pre, convert to <p>
|
|
187
|
+
const text = pre.textContent || '';
|
|
188
|
+
const isAtStart = sel.anchorOffset === 0;
|
|
189
|
+
const isEmpty = text === '' || text === '\n';
|
|
190
|
+
if (isAtStart && isEmpty) {
|
|
191
|
+
e.preventDefault();
|
|
192
|
+
const p = doc.createElement('p');
|
|
193
|
+
p.appendChild(doc.createElement('br'));
|
|
194
|
+
pre.parentNode?.replaceChild(p, pre);
|
|
195
|
+
const range = doc.createRange();
|
|
196
|
+
range.selectNodeContents(p);
|
|
197
|
+
range.collapse(true);
|
|
198
|
+
sel.removeAllRanges();
|
|
199
|
+
sel.addRange(range);
|
|
200
|
+
emit('change', element.innerHTML);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
element.addEventListener('keydown', onKeydown);
|
|
206
|
+
element.addEventListener('paste', onPaste);
|
|
207
|
+
element.addEventListener('input', onInput);
|
|
208
|
+
|
|
209
|
+
function findAncestor(node: Node, tagName: string): Element | null {
|
|
210
|
+
let current: Node | null = node;
|
|
211
|
+
while (current && current !== element) {
|
|
212
|
+
if (current.nodeType === 1 && (current as Element).tagName === tagName) return current as Element;
|
|
213
|
+
current = current.parentNode;
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function hasAncestor(node: Node, tagName: string): boolean {
|
|
219
|
+
let current: Node | null = node;
|
|
220
|
+
while (current && current !== element) {
|
|
221
|
+
if (current.nodeType === 1 && (current as Element).tagName === tagName) return true;
|
|
222
|
+
current = current.parentNode;
|
|
223
|
+
}
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const editor: Editor = {
|
|
228
|
+
exec(command: string, value?: string): void {
|
|
229
|
+
if (!SUPPORTED_COMMANDS.has(command)) {
|
|
230
|
+
throw new Error(`Unknown editor command: "${command}"`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
element.focus();
|
|
234
|
+
|
|
235
|
+
switch (command) {
|
|
236
|
+
case 'bold':
|
|
237
|
+
doc.execCommand('bold', false);
|
|
238
|
+
break;
|
|
239
|
+
case 'italic':
|
|
240
|
+
doc.execCommand('italic', false);
|
|
241
|
+
break;
|
|
242
|
+
case 'heading': {
|
|
243
|
+
const level = value ?? '1';
|
|
244
|
+
if (!['1', '2', '3'].includes(level)) {
|
|
245
|
+
throw new Error(`Invalid heading level: "${level}". Use 1, 2, or 3`);
|
|
246
|
+
}
|
|
247
|
+
doc.execCommand('formatBlock', false, `<h${level}>`);
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
case 'blockquote':
|
|
251
|
+
doc.execCommand('formatBlock', false, '<blockquote>');
|
|
252
|
+
break;
|
|
253
|
+
case 'unorderedList':
|
|
254
|
+
doc.execCommand('insertUnorderedList', false);
|
|
255
|
+
break;
|
|
256
|
+
case 'orderedList':
|
|
257
|
+
doc.execCommand('insertOrderedList', false);
|
|
258
|
+
break;
|
|
259
|
+
case 'link': {
|
|
260
|
+
if (!value) {
|
|
261
|
+
throw new Error('Link command requires a URL value');
|
|
262
|
+
}
|
|
263
|
+
const trimmed = value.trim();
|
|
264
|
+
if (!isProtocolAllowed(trimmed, policy.protocols)) {
|
|
265
|
+
emit('error', new Error(`Protocol not allowed: ${trimmed}`));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
doc.execCommand('createLink', false, trimmed);
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
case 'unlink':
|
|
272
|
+
doc.execCommand('unlink', false);
|
|
273
|
+
break;
|
|
274
|
+
case 'codeBlock': {
|
|
275
|
+
const sel = doc.getSelection();
|
|
276
|
+
if (!sel || sel.rangeCount === 0) break;
|
|
277
|
+
const anchor = sel.anchorNode;
|
|
278
|
+
const pre = anchor ? findAncestor(anchor, 'PRE') : null;
|
|
279
|
+
if (pre) {
|
|
280
|
+
// Toggle off: unwrap <pre><code> to <p>
|
|
281
|
+
const p = doc.createElement('p');
|
|
282
|
+
p.textContent = pre.textContent || '';
|
|
283
|
+
pre.parentNode?.replaceChild(p, pre);
|
|
284
|
+
const r = doc.createRange();
|
|
285
|
+
r.selectNodeContents(p);
|
|
286
|
+
r.collapse(false);
|
|
287
|
+
sel.removeAllRanges();
|
|
288
|
+
sel.addRange(r);
|
|
289
|
+
} else {
|
|
290
|
+
// Wrap current block in <pre><code>
|
|
291
|
+
const range = sel.getRangeAt(0);
|
|
292
|
+
let block = range.startContainer;
|
|
293
|
+
while (block.parentNode && block.parentNode !== element) {
|
|
294
|
+
block = block.parentNode;
|
|
295
|
+
}
|
|
296
|
+
const pre2 = doc.createElement('pre');
|
|
297
|
+
const code = doc.createElement('code');
|
|
298
|
+
const blockText = block.textContent || '';
|
|
299
|
+
code.textContent = blockText.endsWith('\n') ? blockText : blockText + '\n';
|
|
300
|
+
pre2.appendChild(code);
|
|
301
|
+
if (block.parentNode === element) {
|
|
302
|
+
element.replaceChild(pre2, block);
|
|
303
|
+
} else {
|
|
304
|
+
element.appendChild(pre2);
|
|
305
|
+
}
|
|
306
|
+
const r = doc.createRange();
|
|
307
|
+
r.selectNodeContents(code);
|
|
308
|
+
r.collapse(false);
|
|
309
|
+
sel.removeAllRanges();
|
|
310
|
+
sel.addRange(r);
|
|
311
|
+
}
|
|
312
|
+
emit('change', element.innerHTML);
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
|
|
318
|
+
queryState(command: string): boolean {
|
|
319
|
+
if (!SUPPORTED_COMMANDS.has(command)) {
|
|
320
|
+
throw new Error(`Unknown editor command: "${command}"`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const sel = doc.getSelection();
|
|
324
|
+
if (!sel || sel.rangeCount === 0) return false;
|
|
325
|
+
|
|
326
|
+
const node = sel.anchorNode;
|
|
327
|
+
if (!node || !element.contains(node)) return false;
|
|
328
|
+
|
|
329
|
+
switch (command) {
|
|
330
|
+
case 'bold':
|
|
331
|
+
return hasAncestor(node, 'STRONG') || hasAncestor(node, 'B');
|
|
332
|
+
case 'italic':
|
|
333
|
+
return hasAncestor(node, 'EM') || hasAncestor(node, 'I');
|
|
334
|
+
case 'heading':
|
|
335
|
+
return hasAncestor(node, 'H1') || hasAncestor(node, 'H2') || hasAncestor(node, 'H3');
|
|
336
|
+
case 'blockquote':
|
|
337
|
+
return hasAncestor(node, 'BLOCKQUOTE');
|
|
338
|
+
case 'unorderedList':
|
|
339
|
+
return hasAncestor(node, 'UL');
|
|
340
|
+
case 'orderedList':
|
|
341
|
+
return hasAncestor(node, 'OL');
|
|
342
|
+
case 'link':
|
|
343
|
+
return hasAncestor(node, 'A');
|
|
344
|
+
case 'unlink':
|
|
345
|
+
return false;
|
|
346
|
+
case 'codeBlock':
|
|
347
|
+
return hasAncestor(node, 'PRE');
|
|
348
|
+
default:
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
getHTML(): string {
|
|
354
|
+
return element.innerHTML;
|
|
355
|
+
},
|
|
356
|
+
|
|
357
|
+
getText(): string {
|
|
358
|
+
return element.textContent ?? '';
|
|
359
|
+
},
|
|
360
|
+
|
|
361
|
+
destroy(): void {
|
|
362
|
+
element.removeEventListener('keydown', onKeydown);
|
|
363
|
+
element.removeEventListener('paste', onPaste);
|
|
364
|
+
element.removeEventListener('input', onInput);
|
|
365
|
+
enforcer.destroy();
|
|
366
|
+
element.contentEditable = 'false';
|
|
367
|
+
},
|
|
368
|
+
|
|
369
|
+
on(event: string, handler: EventHandler): void {
|
|
370
|
+
if (!handlers[event]) handlers[event] = [];
|
|
371
|
+
handlers[event].push(handler);
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
return editor;
|
|
376
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
SanitizePolicy,
|
|
3
|
+
EditorOptions,
|
|
4
|
+
Editor,
|
|
5
|
+
ToolbarOptions,
|
|
6
|
+
Toolbar,
|
|
7
|
+
} from './types';
|
|
8
|
+
export { DEFAULT_POLICY } from './defaults';
|
|
9
|
+
export { sanitize, sanitizeToFragment } from './sanitize';
|
|
10
|
+
export { createPolicyEnforcer } from './policy';
|
|
11
|
+
export type { PolicyEnforcer } from './policy';
|
|
12
|
+
export { createEditor } from './editor';
|
|
13
|
+
export { createToolbar } from './toolbar';
|
package/src/policy.ts
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type { SanitizePolicy } from './types';
|
|
2
|
+
import { TAG_NORMALIZE, URL_ATTRS, isProtocolAllowed } from './shared';
|
|
3
|
+
|
|
4
|
+
export { DEFAULT_POLICY } from './defaults';
|
|
5
|
+
export type { SanitizePolicy } from './types';
|
|
6
|
+
|
|
7
|
+
export interface PolicyEnforcer {
|
|
8
|
+
destroy(): void;
|
|
9
|
+
on(event: 'error', handler: (error: Error) => void): void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Get the nesting depth of a node within a root element.
|
|
14
|
+
*/
|
|
15
|
+
function getDepth(node: Node, root: Node): number {
|
|
16
|
+
let depth = 0;
|
|
17
|
+
let current = node.parentNode;
|
|
18
|
+
while (current && current !== root) {
|
|
19
|
+
if (current.nodeType === 1) depth++;
|
|
20
|
+
current = current.parentNode;
|
|
21
|
+
}
|
|
22
|
+
return depth;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Check if an element is allowed by the policy and fix it if not.
|
|
27
|
+
* Returns true if the node was removed/replaced.
|
|
28
|
+
*/
|
|
29
|
+
function enforceElement(
|
|
30
|
+
el: Element,
|
|
31
|
+
policy: SanitizePolicy,
|
|
32
|
+
root: HTMLElement,
|
|
33
|
+
): boolean {
|
|
34
|
+
let tagName = el.tagName.toLowerCase();
|
|
35
|
+
const normalized = TAG_NORMALIZE[tagName];
|
|
36
|
+
if (normalized) tagName = normalized;
|
|
37
|
+
|
|
38
|
+
// Check depth
|
|
39
|
+
const depth = getDepth(el, root);
|
|
40
|
+
if (depth >= policy.maxDepth) {
|
|
41
|
+
el.parentNode?.removeChild(el);
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Check tag whitelist
|
|
46
|
+
const allowedAttrs = policy.tags[tagName];
|
|
47
|
+
if (allowedAttrs === undefined) {
|
|
48
|
+
if (policy.strip) {
|
|
49
|
+
el.parentNode?.removeChild(el);
|
|
50
|
+
} else {
|
|
51
|
+
// Unwrap: move children up, then remove the element
|
|
52
|
+
const parent = el.parentNode;
|
|
53
|
+
if (parent) {
|
|
54
|
+
while (el.firstChild) {
|
|
55
|
+
parent.insertBefore(el.firstChild, el);
|
|
56
|
+
}
|
|
57
|
+
parent.removeChild(el);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Normalize tag if needed (e.g. <b> → <strong>)
|
|
64
|
+
let current: Element = el;
|
|
65
|
+
if (normalized && el.tagName.toLowerCase() !== normalized) {
|
|
66
|
+
const replacement = el.ownerDocument.createElement(normalized);
|
|
67
|
+
while (el.firstChild) {
|
|
68
|
+
replacement.appendChild(el.firstChild);
|
|
69
|
+
}
|
|
70
|
+
// Copy allowed attributes
|
|
71
|
+
for (const attr of Array.from(el.attributes)) {
|
|
72
|
+
replacement.setAttribute(attr.name, attr.value);
|
|
73
|
+
}
|
|
74
|
+
el.parentNode?.replaceChild(replacement, el);
|
|
75
|
+
current = replacement;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Strip disallowed attributes
|
|
79
|
+
for (const attr of Array.from(current.attributes)) {
|
|
80
|
+
const attrName = attr.name.toLowerCase();
|
|
81
|
+
|
|
82
|
+
if (attrName.startsWith('on')) {
|
|
83
|
+
current.removeAttribute(attr.name);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!allowedAttrs.includes(attrName)) {
|
|
88
|
+
current.removeAttribute(attr.name);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (URL_ATTRS.has(attrName)) {
|
|
93
|
+
if (!isProtocolAllowed(attr.value, policy.protocols)) {
|
|
94
|
+
current.removeAttribute(attr.name);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Recursively enforce policy on all descendants of a node.
|
|
104
|
+
*/
|
|
105
|
+
function enforceSubtree(node: Node, policy: SanitizePolicy, root: HTMLElement): void {
|
|
106
|
+
const children = Array.from(node.childNodes);
|
|
107
|
+
for (const child of children) {
|
|
108
|
+
if (child.nodeType !== 1) {
|
|
109
|
+
// Remove non-text, non-element nodes (comments, etc.)
|
|
110
|
+
if (child.nodeType !== 3) {
|
|
111
|
+
node.removeChild(child);
|
|
112
|
+
}
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const removed = enforceElement(child as Element, policy, root);
|
|
116
|
+
if (!removed) {
|
|
117
|
+
enforceSubtree(child, policy, root);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Create a policy enforcer that uses MutationObserver to enforce
|
|
124
|
+
* the sanitization policy on a live DOM element.
|
|
125
|
+
*
|
|
126
|
+
* This is defense-in-depth — the paste handler is the primary security boundary.
|
|
127
|
+
* The observer catches mutations from execCommand, programmatic DOM manipulation,
|
|
128
|
+
* and other sources.
|
|
129
|
+
*/
|
|
130
|
+
export function createPolicyEnforcer(
|
|
131
|
+
element: HTMLElement,
|
|
132
|
+
policy: SanitizePolicy,
|
|
133
|
+
): PolicyEnforcer {
|
|
134
|
+
if (!policy || !policy.tags) {
|
|
135
|
+
throw new TypeError('Policy must have a "tags" property');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let isApplyingFix = false;
|
|
139
|
+
const errorHandlers: Array<(error: Error) => void> = [];
|
|
140
|
+
|
|
141
|
+
function emitError(error: Error): void {
|
|
142
|
+
for (const handler of errorHandlers) {
|
|
143
|
+
handler(error);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const observer = new MutationObserver((mutations) => {
|
|
148
|
+
if (isApplyingFix) return;
|
|
149
|
+
isApplyingFix = true;
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
for (const mutation of mutations) {
|
|
153
|
+
if (mutation.type === 'childList') {
|
|
154
|
+
for (const node of Array.from(mutation.addedNodes)) {
|
|
155
|
+
// Skip text nodes
|
|
156
|
+
if (node.nodeType === 3) continue;
|
|
157
|
+
|
|
158
|
+
// Remove non-element nodes
|
|
159
|
+
if (node.nodeType !== 1) {
|
|
160
|
+
node.parentNode?.removeChild(node);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const removed = enforceElement(node as Element, policy, element);
|
|
165
|
+
if (!removed) {
|
|
166
|
+
// Also enforce on all descendants of the added node
|
|
167
|
+
enforceSubtree(node, policy, element);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
} else if (mutation.type === 'attributes') {
|
|
171
|
+
const target = mutation.target as Element;
|
|
172
|
+
if (target.nodeType !== 1) continue;
|
|
173
|
+
|
|
174
|
+
const attrName = mutation.attributeName;
|
|
175
|
+
if (!attrName) continue;
|
|
176
|
+
|
|
177
|
+
const tagName = target.tagName.toLowerCase();
|
|
178
|
+
const normalizedTag = TAG_NORMALIZE[tagName] || tagName;
|
|
179
|
+
const allowedAttrs = policy.tags[normalizedTag];
|
|
180
|
+
|
|
181
|
+
if (!allowedAttrs) continue;
|
|
182
|
+
|
|
183
|
+
const lowerAttr = attrName.toLowerCase();
|
|
184
|
+
|
|
185
|
+
if (lowerAttr.startsWith('on')) {
|
|
186
|
+
target.removeAttribute(attrName);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!allowedAttrs.includes(lowerAttr)) {
|
|
191
|
+
target.removeAttribute(attrName);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (URL_ATTRS.has(lowerAttr)) {
|
|
196
|
+
const value = target.getAttribute(attrName);
|
|
197
|
+
if (value && !isProtocolAllowed(value, policy.protocols)) {
|
|
198
|
+
target.removeAttribute(attrName);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
} catch (err) {
|
|
204
|
+
emitError(err instanceof Error ? err : new Error(String(err)));
|
|
205
|
+
} finally {
|
|
206
|
+
isApplyingFix = false;
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
observer.observe(element, {
|
|
211
|
+
childList: true,
|
|
212
|
+
attributes: true,
|
|
213
|
+
subtree: true,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
destroy() {
|
|
218
|
+
observer.disconnect();
|
|
219
|
+
},
|
|
220
|
+
on(event: 'error', handler: (error: Error) => void) {
|
|
221
|
+
if (event === 'error') {
|
|
222
|
+
errorHandlers.push(handler);
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|