From 5a3edacb96447b16082286165af1d966ca717022 Mon Sep 17 00:00:00 2001 From: roymondchen Date: Mon, 14 Sep 2026 15:22:02 +0800 Subject: [PATCH] =?UTF-8?q?fix(editor):=20=E7=94=BB=E5=B8=83=20drop=20?= =?UTF-8?q?=E4=BB=85=E8=BF=98=E5=8E=9F=E5=86=85=E9=83=A8=E6=8B=96=E6=8B=BD?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=EF=BC=8C=E9=81=BF=E5=85=8D=E5=A4=96=E9=83=A8?= =?UTF-8?q?=E6=BA=90=E8=A7=A6=E5=8F=91=20parseDSL=20=E6=89=A7=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../src/layouts/workspace/viewer/Stage.vue | 90 +++++++- .../layouts/workspace/viewer/Stage.spec.ts | 218 ++++++++++++++---- 2 files changed, 259 insertions(+), 49 deletions(-) diff --git a/packages/editor/src/layouts/workspace/viewer/Stage.vue b/packages/editor/src/layouts/workspace/viewer/Stage.vue index 0d241acc..945f010b 100644 --- a/packages/editor/src/layouts/workspace/viewer/Stage.vue +++ b/packages/editor/src/layouts/workspace/viewer/Stage.vue @@ -56,7 +56,14 @@ import { calcValueByFontsize, getIdFromEl } from '@tmagic/utils'; import ScrollViewer from '@editor/components/ScrollViewer.vue'; import { useServices } from '@editor/hooks'; import { useStage } from '@editor/hooks/use-stage'; -import type { CustomContentMenuFunction, MenuButton, MenuComponent, StageOptions, StageSlots } from '@editor/type'; +import type { + AddMNode, + CustomContentMenuFunction, + MenuButton, + MenuComponent, + StageOptions, + StageSlots, +} from '@editor/type'; import { DragType, Layout } from '@editor/type'; import { getEditorConfig } from '@editor/utils/config'; import { KeyBindingContainerKey } from '@editor/utils/keybinding-config'; @@ -270,11 +277,56 @@ const resizeObserver = new globalThis.ResizeObserver((entries) => { } }); +const parseDSL = getEditorConfig('parseDSL'); + +/** + * 本次拖拽是否由编辑器文档内部发起 + * + * drop 的 text/json 里可能带函数(组件配置的事件、钩子等),还原只能交给 parseDSL, + * 而 parseDSL 的默认实现是 eval;HTML 拖放又允许其他源的页面在 DataTransfer 中投递 + * 自定义 MIME 数据,跨源页面只要诱导用户拖拽一次,就能让 eval 执行任意脚本。 + * + * 跨源页面既不会在本文档触发 dragstart,也无法往本文档创建的 DataTransfer 中写数据, + * 因此只有起源于本文档的拖拽才交给 parseDSL 还原。 + * 同源拖拽源写入的 text/json 由业务保证可信。 + */ +let isInternalDrag = false; +let internalDragSession = 0; +let clearInternalDragTimer: ReturnType | undefined; + +const documentDragStartHandler = () => { + internalDragSession += 1; + isInternalDrag = true; + if (clearInternalDragTimer !== undefined) { + globalThis.clearTimeout(clearInternalDragTimer); + clearInternalDragTimer = undefined; + } +}; + +const documentDragEndHandler = () => { + // WebKit 可能先 dragend 再 drop;推迟到下一个宏任务再清标志, + // 让同一次拖拽的 drop 仍能消费。session 防止误清掉下一次 dragstart。 + const session = internalDragSession; + if (clearInternalDragTimer !== undefined) { + globalThis.clearTimeout(clearInternalDragTimer); + } + clearInternalDragTimer = globalThis.setTimeout(() => { + clearInternalDragTimer = undefined; + if (session === internalDragSession) { + isInternalDrag = false; + } + }, 0); +}; + onMounted(() => { if (stageWrapRef.value?.container) { resizeObserver.observe(stageWrapRef.value.container); keybindingService.registerEl(KeyBindingContainerKey.STAGE, stageWrapRef.value.container); } + + // 用捕获阶段监听,确保拖拽源自身的 dragstart 无论是否阻止冒泡都能被记录 + globalThis.document.addEventListener('dragstart', documentDragStartHandler, true); + globalThis.document.addEventListener('dragend', documentDragEndHandler, true); }); onBeforeUnmount(() => { @@ -283,9 +335,15 @@ onBeforeUnmount(() => { resizeObserver.disconnect(); editorService.set('stage', null); keybindingService.unregisterEl('stage'); -}); -const parseDSL = getEditorConfig('parseDSL'); + if (clearInternalDragTimer !== undefined) { + globalThis.clearTimeout(clearInternalDragTimer); + clearInternalDragTimer = undefined; + } + + globalThis.document.removeEventListener('dragstart', documentDragStartHandler, true); + globalThis.document.removeEventListener('dragend', documentDragEndHandler, true); +}); const contextmenuHandler = (e: MouseEvent) => { e.preventDefault(); @@ -299,15 +357,27 @@ const dragoverHandler = (e: DragEvent) => { }; const dropHandler = async (e: DragEvent) => { + // 画布上的任意 drop 都消费本次内部拖拽标记(含空 text/json),避免残留到下一次 drop + const allowed = isInternalDrag; + isInternalDrag = false; + if (!e.dataTransfer) return; const data = e.dataTransfer.getData('text/json'); - if (!data) return; + // 外部源投递的拖拽数据不可信,不能交给 parseDSL(默认实现为 eval) + if (!data || !allowed) return; - const config = parseDSL(`(${data})`); + let config: { dragType?: string; data?: AddMNode } | undefined; + try { + config = parseDSL(`(${data})`); + } catch { + return; + } - if (!config || config.dragType !== DragType.COMPONENT_LIST) return; + if (!config || config.dragType !== DragType.COMPONENT_LIST || !config.data) return; + + const dragData = config.data; e.preventDefault(); @@ -349,7 +419,7 @@ const dropHandler = async (e: DragEvent) => { const containerRect = stageContainerEl.value.getBoundingClientRect(); const { scrollTop, scrollLeft } = stage.mask!; - const { style = {} } = config.data; + const { style = {} } = dragData; let top = 0; let left = 0; @@ -371,16 +441,16 @@ const dropHandler = async (e: DragEvent) => { } } - config.data.style = { + dragData.style = { ...style, position, top: calcValueByFontsize(doc, top / zoom.value), left: calcValueByFontsize(doc, left / zoom.value), }; - config.data.inputEvent = e; + dragData.inputEvent = e; - editorService.add(config.data, parent, { historySource: 'component-panel' }); + editorService.add(dragData, parent, { historySource: 'component-panel' }); } }; diff --git a/packages/editor/tests/unit/layouts/workspace/viewer/Stage.spec.ts b/packages/editor/tests/unit/layouts/workspace/viewer/Stage.spec.ts index 8367899a..8eb14aa3 100644 --- a/packages/editor/tests/unit/layouts/workspace/viewer/Stage.spec.ts +++ b/packages/editor/tests/unit/layouts/workspace/viewer/Stage.spec.ts @@ -55,11 +55,14 @@ vi.mock('@editor/hooks', () => ({ useServices: () => ({ editorService, uiService, keybindingService, stageOverlayService }), })); +// 与 plugin.ts 中 parseDSL 的默认实现(eval)保持一致,用于验证守卫能否阻止其执行 +const { parseDSL } = vi.hoisted(() => ({ + // eslint-disable-next-line no-new-func + parseDSL: vi.fn((dsl: string) => new Function(`return ${dsl}`)()), +})); + vi.mock('@editor/utils/config', () => ({ - getEditorConfig: vi.fn(() => (s: string) => { - if (s.startsWith('(')) return JSON.parse(s.slice(1, -1)); - return JSON.parse(s); - }), + getEditorConfig: vi.fn(() => parseDSL), })); vi.mock('@editor/components/ScrollViewer.vue', () => ({ @@ -146,6 +149,26 @@ const mountIt = (props: any = {}) => attachTo: document.body, }); +/** 模拟编辑器文档内部(如组件列表面板)发起拖拽 */ +const startInternalDrag = () => { + document.dispatchEvent(new Event('dragstart')); +}; + +const endDrag = () => { + document.dispatchEvent(new Event('dragend')); +}; + +const createDropEvent = (raw: string) => { + const event: any = new Event('drop'); + event.dataTransfer = { getData: vi.fn(() => raw) }; + event.preventDefault = vi.fn(); + return event; +}; + +const waitMacrotask = () => new Promise((r) => setTimeout(r, 10)); + +const COMPONENT_LIST_JSON = '{"dragType":"component-list","data":{"name":"text","style":{}}}'; + describe('Stage', () => { test('挂载并创建 stage', async () => { const wrapper = mountIt(); @@ -185,17 +208,28 @@ describe('Stage', () => { editorService.getNodeById.mockReturnValue(null); const wrapper = mountIt(); await nextTick(); - const event: any = new Event('drop'); - event.dataTransfer = { - getData: vi.fn(() => '{"dragType":"component-list","data":{"name":"text","style":{}}}'), - }; + const event = createDropEvent(COMPONENT_LIST_JSON); event.clientX = 100; event.clientY = 100; - event.preventDefault = vi.fn(); const stageContainer = wrapper.find('.m-editor-stage-container').element; + startInternalDrag(); stageContainer.dispatchEvent(event); - await new Promise((r) => setTimeout(r, 10)); + await waitMacrotask(); expect(editorService.add).toHaveBeenCalled(); + endDrag(); + }); + + test('drop 保留拖拽数据中的函数', async () => { + editorService.getNodeById.mockReturnValue(null); + const wrapper = mountIt(); + await nextTick(); + const event = createDropEvent('{dragType:"component-list",data:{name:"text",style:{},created:() => "created"}}'); + event.clientX = 10; + event.clientY = 10; + startInternalDrag(); + wrapper.find('.m-editor-stage-container').element.dispatchEvent(event); + await new Promise((r) => setTimeout(r, 10)); + expect(editorService.add.mock.calls[0][0].created()).toBe('created'); }); test('zoom 变化时 stage.setZoom 被调用', async () => { @@ -218,48 +252,163 @@ describe('Stage', () => { expect(wrapper.find('.fake-overlay').exists()).toBe(false); }); - test('drop 数据为空时不处理', async () => { + test('drop 数据为空时消费内部拖拽标记,后续 drop 不再解析', async () => { const wrapper = mountIt(); await nextTick(); - const event: any = new Event('drop'); - event.dataTransfer = { getData: vi.fn(() => '') }; - event.preventDefault = vi.fn(); const stageContainer = wrapper.find('.m-editor-stage-container').element; + startInternalDrag(); + stageContainer.dispatchEvent(createDropEvent('')); + expect(editorService.add).not.toHaveBeenCalled(); + + stageContainer.dispatchEvent(createDropEvent(COMPONENT_LIST_JSON)); + await waitMacrotask(); + expect(parseDSL).not.toHaveBeenCalled(); + expect(editorService.add).not.toHaveBeenCalled(); + }); + + test('drop 无 dataTransfer 时也消费内部拖拽标记', async () => { + const wrapper = mountIt(); + await nextTick(); + const stageContainer = wrapper.find('.m-editor-stage-container').element; + startInternalDrag(); + const event: any = new Event('drop'); + event.dataTransfer = null; stageContainer.dispatchEvent(event); + stageContainer.dispatchEvent(createDropEvent(COMPONENT_LIST_JSON)); + await waitMacrotask(); + expect(parseDSL).not.toHaveBeenCalled(); expect(editorService.add).not.toHaveBeenCalled(); }); test('drop 非 COMPONENT_LIST 时不处理', async () => { const wrapper = mountIt(); await nextTick(); - const event: any = new Event('drop'); - event.dataTransfer = { - getData: vi.fn(() => '{"dragType":"other","data":{"name":"text","style":{}}}'), - }; - event.preventDefault = vi.fn(); + const event = createDropEvent('{"dragType":"other","data":{"name":"text","style":{}}}'); const stageContainer = wrapper.find('.m-editor-stage-container').element; + startInternalDrag(); stageContainer.dispatchEvent(event); + await waitMacrotask(); + expect(editorService.add).not.toHaveBeenCalled(); + }); + + test('drop 缺少 data 时不处理', async () => { + const wrapper = mountIt(); + await nextTick(); + startInternalDrag(); + wrapper.find('.m-editor-stage-container').element.dispatchEvent(createDropEvent('{"dragType":"component-list"}')); + await waitMacrotask(); + expect(editorService.add).not.toHaveBeenCalled(); + }); + + test('drop 拖拽非本文档发起时不解析数据,不执行其中的脚本', async () => { + let executed = false; + Object.defineProperty(globalThis, '__PWNED__', { + configurable: true, + set() { + executed = true; + }, + }); + const wrapper = mountIt(); + await nextTick(); + // PoC 中攻击页面跨源投递的 payload + const event = createDropEvent("{a:(globalThis.__PWNED__='code execution',0), dragType:'component-list'}"); + const stageContainer = wrapper.find('.m-editor-stage-container').element; + // 不触发 dragstart,模拟拖拽起源于编辑器之外。 + // 同源内部拖拽源写入的 text/json 由业务保证可信,本用例不覆盖。 + expect(() => stageContainer.dispatchEvent(event)).not.toThrow(); + await new Promise((r) => setTimeout(r, 10)); + expect(parseDSL).not.toHaveBeenCalled(); + expect(executed).toBe(false); + expect(editorService.add).not.toHaveBeenCalled(); + }); + + test('drop 一次 dragstart 只消费一次', async () => { + editorService.getNodeById.mockReturnValue(null); + const wrapper = mountIt(); + await nextTick(); + const stageContainer = wrapper.find('.m-editor-stage-container').element; + startInternalDrag(); + stageContainer.dispatchEvent(createDropEvent(COMPONENT_LIST_JSON)); + await waitMacrotask(); + expect(editorService.add).toHaveBeenCalledTimes(1); + + stageContainer.dispatchEvent(createDropEvent(COMPONENT_LIST_JSON)); + await waitMacrotask(); + expect(editorService.add).toHaveBeenCalledTimes(1); + }); + + test('drop 在 dragend 之后仍处理(兼容 WebKit 先 dragend 再 drop)', async () => { + editorService.getNodeById.mockReturnValue(null); + const wrapper = mountIt(); + await nextTick(); + startInternalDrag(); + endDrag(); + wrapper.find('.m-editor-stage-container').element.dispatchEvent(createDropEvent(COMPONENT_LIST_JSON)); + await waitMacrotask(); + expect(editorService.add).toHaveBeenCalled(); + }); + + test('dragend 宏任务之后取消的拖拽不再处理', async () => { + editorService.getNodeById.mockReturnValue(null); + const wrapper = mountIt(); + await nextTick(); + startInternalDrag(); + endDrag(); + await waitMacrotask(); + wrapper.find('.m-editor-stage-container').element.dispatchEvent(createDropEvent(COMPONENT_LIST_JSON)); + await waitMacrotask(); + expect(parseDSL).not.toHaveBeenCalled(); + expect(editorService.add).not.toHaveBeenCalled(); + }); + + test('dragend 延迟清理不会清掉下一次 dragstart', async () => { + editorService.getNodeById.mockReturnValue(null); + const wrapper = mountIt(); + await nextTick(); + startInternalDrag(); + endDrag(); + startInternalDrag(); + await waitMacrotask(); + wrapper.find('.m-editor-stage-container').element.dispatchEvent(createDropEvent(COMPONENT_LIST_JSON)); + await waitMacrotask(); + expect(editorService.add).toHaveBeenCalled(); + }); + + test('drop parseDSL 解析失败时不抛错', async () => { + const wrapper = mountIt(); + await nextTick(); + const event = createDropEvent('{"dragType":'); + const stageContainer = wrapper.find('.m-editor-stage-container').element; + startInternalDrag(); + expect(() => stageContainer.dispatchEvent(event)).not.toThrow(); await new Promise((r) => setTimeout(r, 10)); expect(editorService.add).not.toHaveBeenCalled(); }); + test('卸载时移除 document 上的拖拽监听', async () => { + const removeSpy = vi.spyOn(document, 'removeEventListener'); + const wrapper = mountIt(); + await nextTick(); + wrapper.unmount(); + expect(removeSpy).toHaveBeenCalledWith('dragstart', expect.any(Function), true); + expect(removeSpy).toHaveBeenCalledWith('dragend', expect.any(Function), true); + removeSpy.mockRestore(); + }); + test('drop position fixed 计算位置', async () => { editorService.getNodeById.mockReturnValue(null); editorService.getLayout.mockResolvedValue('relative'); const wrapper = mountIt(); await nextTick(); - const event: any = new Event('drop'); - event.dataTransfer = { - getData: vi.fn(() => '{"dragType":"component-list","data":{"name":"text","style":{"position":"fixed"}}}'), - }; + const event = createDropEvent('{"dragType":"component-list","data":{"name":"text","style":{"position":"fixed"}}}'); event.clientX = 80; event.clientY = 60; - event.preventDefault = vi.fn(); const stageContainer = wrapper.find('.m-editor-stage-container').element; Object.defineProperty(stageContainer, 'getBoundingClientRect', { value: () => ({ left: 0, top: 0, width: 800, height: 600 }), configurable: true, }); + startInternalDrag(); stageContainer.dispatchEvent(event); await new Promise((r) => setTimeout(r, 10)); const args = editorService.add.mock.calls[0][0]; @@ -271,18 +420,15 @@ describe('Stage', () => { editorService.getLayout.mockResolvedValue('absolute'); const wrapper = mountIt(); await nextTick(); - const event: any = new Event('drop'); - event.dataTransfer = { - getData: vi.fn(() => '{"dragType":"component-list","data":{"name":"text","style":{}}}'), - }; + const event = createDropEvent('{"dragType":"component-list","data":{"name":"text","style":{}}}'); event.clientX = 80; event.clientY = 60; - event.preventDefault = vi.fn(); const stageContainer = wrapper.find('.m-editor-stage-container').element; Object.defineProperty(stageContainer, 'getBoundingClientRect', { value: () => ({ left: 0, top: 0, width: 800, height: 600 }), configurable: true, }); + startInternalDrag(); stageContainer.dispatchEvent(event); await new Promise((r) => setTimeout(r, 10)); const args = editorService.add.mock.calls[0][0]; @@ -296,14 +442,11 @@ describe('Stage', () => { stageOptions: { runtimeUrl: 'http://x', containerHighlightClassName: 'highlight', canDropIn }, }); await nextTick(); - const event: any = new Event('drop'); - event.dataTransfer = { - getData: vi.fn(() => '{"dragType":"component-list","data":{"name":"text","style":{}}}'), - }; + const event = createDropEvent('{"dragType":"component-list","data":{"name":"text","style":{}}}'); event.clientX = 50; event.clientY = 50; - event.preventDefault = vi.fn(); const stageContainer = wrapper.find('.m-editor-stage-container').element; + startInternalDrag(); stageContainer.dispatchEvent(event); await new Promise((r) => setTimeout(r, 10)); expect(canDropIn).toHaveBeenCalled(); @@ -414,13 +557,10 @@ describe('Stage', () => { stageOptions: { runtimeUrl: 'http://x', containerHighlightClassName: 'h', canDropIn }, }); await nextTick(); - const event: any = new Event('drop'); - event.dataTransfer = { - getData: vi.fn(() => '{"dragType":"component-list","data":{"name":"text","style":{}}}'), - }; + const event = createDropEvent('{"dragType":"component-list","data":{"name":"text","style":{}}}'); event.clientX = 1; event.clientY = 1; - event.preventDefault = vi.fn(); + startInternalDrag(); wrapper.find('.m-editor-stage-container').element.dispatchEvent(event); await new Promise((r) => setTimeout(r, 10)); expect(canDropIn).toHaveBeenCalled();