Skip to content

Commit e429c23

Browse files
authored
Prevent drop malicious content on the editor (#3319)
Introduced protection to prevent potentially harmful HTML content from being added to the editor through drag-and-drop. The DragAndDrop Plugin was implemented to manage external content drops, block the default drop action, sanitize any dropped content, and insert only the sanitized content into the editor.
1 parent 0ba436b commit e429c23

File tree

19 files changed

+1056
-14
lines changed

19 files changed

+1056
-14
lines changed

demo/scripts/controlsV2/mainPane/MainPane.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import {
6262
AnnouncePlugin,
6363
AutoFormatPlugin,
6464
CustomReplacePlugin,
65+
DragAndDropPlugin,
6566
EditPlugin,
6667
HiddenPropertyPlugin,
6768
HyperlinkPlugin,
@@ -577,6 +578,7 @@ export class MainPane extends React.Component<{}, MainPaneState> {
577578
}),
578579
pluginList.touch && new TouchPlugin(),
579580
pluginList.announce && new AnnouncePlugin(),
581+
pluginList.dragAndDrop && new DragAndDropPlugin(),
580582
].filter(x => !!x);
581583
}
582584
}

demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const initialState: OptionState = {
2424
hiddenProperty: true,
2525
touch: true,
2626
announce: true,
27+
dragAndDrop: true,
2728
},
2829
defaultFormat: {
2930
fontFamily: 'Calibri',

demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export interface BuildInPluginList {
2626
hiddenProperty: boolean;
2727
touch: boolean;
2828
announce: boolean;
29+
dragAndDrop: boolean;
2930
}
3031

3132
export interface OptionState {

demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,7 @@ export class Plugins extends PluginsBase<keyof BuildInPluginList> {
360360
{this.renderPluginItem('hiddenProperty', 'Hidden Property')}
361361
{this.renderPluginItem('touch', 'Touch')}
362362
{this.renderPluginItem('announce', 'Announce')}
363+
{this.renderPluginItem('dragAndDrop', 'DragAndDrop')}
363364
</tbody>
364365
</table>
365366
);

demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { OptionState } from '../OptionState';
55
import { WatermarkCode } from './WatermarkCode';
66

77
import {
8+
DragAndDropPluginCode,
89
EditPluginCode,
910
PastePluginCode,
1011
TableEditPluginCode,
@@ -45,6 +46,7 @@ export class PluginsCode extends PluginsCodeBase {
4546
pluginList.watermark && new WatermarkCode(state.watermarkText),
4647
pluginList.markdown && new MarkdownCode(state.markdownOptions),
4748
pluginList.imageEditPlugin && new ImageEditPluginCode(),
49+
pluginList.dragAndDrop && new DragAndDropPluginCode(),
4850
]);
4951
}
5052
}

demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,9 @@ export class ImageEditPluginCode extends SimplePluginCode {
3939
super('ImageEditPlugin');
4040
}
4141
}
42+
43+
export class DragAndDropPluginCode extends SimplePluginCode {
44+
constructor() {
45+
super('DragAndDropPlugin');
46+
}
47+
}

packages/roosterjs-content-model-core/lib/corePlugin/domEvent/DOMEventPlugin.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ class DOMEventPlugin implements PluginWithState<DOMEventPluginState> {
8585

8686
// 4. Drag and Drop event
8787
dragstart: { beforeDispatch: this.onDragStart },
88-
drop: { beforeDispatch: this.onDrop },
88+
drop: { beforeDispatch: (event: DragEvent) => this.onDrop(event) },
8989

9090
// 5. Pointer event
9191
pointerdown: { beforeDispatch: (event: PointerEvent) => this.onPointerDown(event) },
@@ -137,17 +137,23 @@ class DOMEventPlugin implements PluginWithState<DOMEventPluginState> {
137137
}
138138
};
139139

140-
private onDrop = () => {
141-
const doc = this.editor?.getDocument();
142-
143-
doc?.defaultView?.requestAnimationFrame(() => {
144-
if (this.editor) {
145-
this.editor.takeSnapshot();
146-
this.editor.triggerEvent('contentChanged', {
147-
source: ChangeSource.Drop,
140+
private onDrop = (e: DragEvent) => {
141+
if (this.editor) {
142+
const beforeDropEvent = this.editor.triggerEvent('beforeDrop', {
143+
rawEvent: e,
144+
});
145+
if (!beforeDropEvent?.rawEvent.defaultPrevented) {
146+
const doc = this.editor.getDocument();
147+
doc?.defaultView?.requestAnimationFrame(() => {
148+
if (this.editor) {
149+
this.editor.takeSnapshot();
150+
this.editor.triggerEvent('contentChanged', {
151+
source: ChangeSource.Drop,
152+
});
153+
}
148154
});
149155
}
150-
});
156+
}
151157
};
152158

153159
private onScroll = (e: Event) => {

packages/roosterjs-content-model-core/test/corePlugin/domEvent/DomEventPluginTest.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,6 @@ describe('DOMEventPlugin verify event handlers while disallow keyboard event pro
209209
expect(triggerEventSpy).toHaveBeenCalled();
210210
});
211211

212-
213212
it('verify input event for non-character value', () => {
214213
spyOn(eventUtils, 'isCharacterValue').and.returnValue(false);
215214
const stopPropagation = jasmine.createSpy();
@@ -282,7 +281,6 @@ describe('DOMEventPlugin verify event handlers while disallow keyboard event pro
282281
expect(stopPropagation).toHaveBeenCalled();
283282
expect(triggerEventSpy).toHaveBeenCalled();
284283
});
285-
286284
});
287285

288286
describe('DOMEventPlugin handle mouse down and mouse up event', () => {
@@ -553,18 +551,50 @@ describe('DOMEventPlugin handle other event', () => {
553551
it('Trigger onDrop event', () => {
554552
const takeSnapshotSpy = jasmine.createSpy('takeSnapshot');
555553
editor.takeSnapshot = takeSnapshotSpy;
554+
const mockedEvent = {
555+
dataTransfer: {
556+
getData: () => '',
557+
},
558+
defaultPrevented: false,
559+
} as any;
556560

557-
eventMap.drop.beforeDispatch();
561+
triggerEvent.and.returnValue({ rawEvent: mockedEvent });
562+
563+
eventMap.drop.beforeDispatch(mockedEvent);
558564
expect(plugin.getState()).toEqual({
559565
isInIME: false,
560566
scrollContainer: scrollContainer,
561567
mouseDownX: null,
562568
mouseDownY: null,
563569
mouseUpEventListerAdded: false,
564570
});
571+
expect(triggerEvent).toHaveBeenCalledWith('beforeDrop', {
572+
rawEvent: mockedEvent,
573+
});
565574
expect(takeSnapshotSpy).toHaveBeenCalledWith();
566575
expect(triggerEvent).toHaveBeenCalledWith('contentChanged', {
567576
source: ChangeSource.Drop,
568577
});
569578
});
579+
580+
it('Trigger onDrop event with defaultPrevented', () => {
581+
const takeSnapshotSpy = jasmine.createSpy('takeSnapshot');
582+
editor.takeSnapshot = takeSnapshotSpy;
583+
const mockedEvent = {
584+
dataTransfer: {
585+
getData: () => '',
586+
},
587+
defaultPrevented: true,
588+
} as any;
589+
590+
triggerEvent.and.returnValue({ rawEvent: mockedEvent });
591+
592+
eventMap.drop.beforeDispatch(mockedEvent);
593+
594+
expect(triggerEvent).toHaveBeenCalledWith('beforeDrop', {
595+
rawEvent: mockedEvent,
596+
});
597+
expect(takeSnapshotSpy).not.toHaveBeenCalled();
598+
expect(triggerEvent).not.toHaveBeenCalledWith('contentChanged', jasmine.anything());
599+
});
570600
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { handleDroppedContent } from './utils/handleDroppedContent';
2+
import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-content-model-types';
3+
4+
/**
5+
* Options for DragAndDrop plugin
6+
*/
7+
export interface DragAndDropOptions {
8+
/**
9+
* Forbidden elements that cannot be dropped in the editor
10+
* @default ['iframe']
11+
*/
12+
forbiddenElements?: string[];
13+
}
14+
15+
const DefaultOptions = {
16+
forbiddenElements: ['iframe'],
17+
};
18+
19+
/**
20+
* DragAndDrop plugin, handles ContentChanged event when change source is "Drop"
21+
* to sanitize dropped content, similar to how PastePlugin sanitizes pasted content.
22+
*/
23+
export class DragAndDropPlugin implements EditorPlugin {
24+
private editor: IEditor | null = null;
25+
private forbiddenElements: string[] = [];
26+
private isInternalDragging: boolean = false;
27+
private disposer: (() => void) | null = null;
28+
29+
/**
30+
* Construct a new instance of DragAndDropPlugin
31+
*/
32+
constructor(options: DragAndDropOptions = DefaultOptions) {
33+
this.forbiddenElements = options.forbiddenElements ?? [];
34+
}
35+
36+
/**
37+
* Get name of this plugin
38+
*/
39+
getName() {
40+
return 'DragAndDrop';
41+
}
42+
43+
/**
44+
* The first method that editor will call to a plugin when editor is initializing.
45+
* It will pass in the editor instance, plugin should take this chance to save the
46+
* editor reference so that it can call to any editor method or format API later.
47+
* @param editor The editor object
48+
*/
49+
initialize(editor: IEditor) {
50+
this.editor = editor;
51+
this.disposer = editor.attachDomEvent({
52+
dragstart: {
53+
beforeDispatch: _ev => {
54+
this.isInternalDragging = true;
55+
},
56+
},
57+
});
58+
}
59+
60+
/**
61+
* The last method that editor will call to a plugin before it is disposed.
62+
* Plugin can take this chance to clear the reference to editor. After this method is
63+
* called, plugin should not call to any editor method since it will result in error.
64+
*/
65+
dispose() {
66+
this.editor = null;
67+
if (this.disposer) {
68+
this.disposer();
69+
this.disposer = null;
70+
}
71+
this.isInternalDragging = false;
72+
this.forbiddenElements = [];
73+
}
74+
75+
/**
76+
* Core method for a plugin. Once an event happens in editor, editor will call this
77+
* method of each plugin to handle the event as long as the event is not handled
78+
* exclusively by another plugin.
79+
* @param event The event to handle:
80+
*/
81+
onPluginEvent(event: PluginEvent) {
82+
if (this.editor && event.eventType == 'beforeDrop') {
83+
if (this.isInternalDragging) {
84+
this.isInternalDragging = false;
85+
} else {
86+
const dropEvent = event.rawEvent;
87+
const html = dropEvent.dataTransfer?.getData('text/html');
88+
89+
if (html) {
90+
handleDroppedContent(this.editor, dropEvent, html, this.forbiddenElements);
91+
}
92+
}
93+
return;
94+
}
95+
}
96+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* @internal
3+
* Remove all forbidden elements from a parsed HTML document
4+
* @param doc The parsed HTML document to clean
5+
* @param forbiddenElements Array of tag names to remove (e.g., ['iframe', 'script'])
6+
*/
7+
export function cleanForbiddenElements(doc: Document, forbiddenElements: string[]): void {
8+
if (forbiddenElements.length === 0) {
9+
return;
10+
}
11+
12+
const selector = forbiddenElements.join(',');
13+
const elements = Array.from(doc.body.querySelectorAll(selector));
14+
15+
for (const element of elements) {
16+
element.parentNode?.removeChild(element);
17+
}
18+
}

0 commit comments

Comments
 (0)