diff --git a/docs/api/editor/props.md b/docs/api/editor/props.md index 6e682296..5536d256 100644 --- a/docs/api/editor/props.md +++ b/docs/api/editor/props.md @@ -503,6 +503,32 @@ const renderFunction = async (stage) => { ``` +## iframeSandbox + +- **详情:** + + 画布 iframe 的 sandbox 属性值,仅 `renderType` 为 `iframe` 时生效。不传或传 `false` 时不设置 sandbox 属性。 + + token 用空格分隔,缺哪个 token,画布内对应能力就会静默失效。编辑器自身依赖 `allow-scripts` + `allow-same-origin`(需要读写 iframe 内的 DOM);组件内需要下载、弹窗、表单提交时,分别要放开 `allow-downloads`、`allow-popups`、`allow-forms` 等。 + + 注意: + + - 仅在编辑器初始化时读取,运行期修改不会生效。 + - runtime 与编辑器不同域时,会通过 `srcdoc` 加载,同样受 sandbox 约束;缺少 `allow-same-origin` 会导致编辑器无法访问 iframe 内的 DOM。 + - `allow-scripts` 与 `allow-same-origin` 同时存在且 iframe 同源时,iframe 内脚本可以移除自身的 sandbox 属性,因此该配置只用于限制 `allow-top-navigation`、`allow-popups` 等能力,不能作为安全隔离手段。 + +- **默认值:** `undefined`(不设置 sandbox 属性) + +- **类型:** `string | false` + +- **示例:** + +```html + +``` + ## autoScrollIntoView - **详情:** diff --git a/packages/editor/src/Editor.vue b/packages/editor/src/Editor.vue index 1ebc00d5..5509f1f7 100644 --- a/packages/editor/src/Editor.vue +++ b/packages/editor/src/Editor.vue @@ -224,6 +224,7 @@ const stageOptions: StageOptions = { containerHighlightAddOnly: props.containerHighlightAddOnly, disabledDragStart: props.disabledDragStart, renderType: props.renderType, + iframeSandbox: props.iframeSandbox, guidesOptions: props.guidesOptions, disabledMultiSelect: props.disabledMultiSelect, alwaysMultiSelect: props.alwaysMultiSelect, diff --git a/packages/editor/src/editorProps.ts b/packages/editor/src/editorProps.ts index fa4b9595..d64c2db5 100644 --- a/packages/editor/src/editorProps.ts +++ b/packages/editor/src/editorProps.ts @@ -62,6 +62,16 @@ export interface EditorProps { runtimeUrl?: string; /** 是用iframe渲染还是直接渲染 */ renderType?: RenderType; + /** + * 画布 iframe 的 sandbox 属性值(仅 iframe 渲染时生效) + * + * - 不传 / `false`:不设置 sandbox 属性(默认) + * - 字符串:原样写入,token 空格分隔,如 `'allow-scripts allow-same-origin allow-downloads'` + * + * 需要注意编辑器自身依赖 `allow-scripts` + `allow-same-origin`(读写 iframe 内 DOM), + * 其余 token(`allow-downloads`、`allow-popups`、`allow-forms` 等)按业务需要放开。 + */ + iframeSandbox?: string | false; /** 选中时是否自动滚动到可视区域 */ autoScrollIntoView?: boolean; /** 组件的属性配置表单的dsl */ diff --git a/packages/editor/src/hooks/use-stage.ts b/packages/editor/src/hooks/use-stage.ts index 0bece9e0..c2724c94 100644 --- a/packages/editor/src/hooks/use-stage.ts +++ b/packages/editor/src/hooks/use-stage.ts @@ -31,6 +31,7 @@ export const useStage = (stageOptions: StageOptions) => { containerHighlightAddOnly: stageOptions.containerHighlightAddOnly, disabledDragStart: stageOptions.disabledDragStart, renderType: stageOptions.renderType, + iframeSandbox: stageOptions.iframeSandbox, canSelect: (el, event, stop) => { if (!stageOptions.canSelect) return true; diff --git a/packages/editor/src/type.ts b/packages/editor/src/type.ts index 2237870f..8aac1968 100644 --- a/packages/editor/src/type.ts +++ b/packages/editor/src/type.ts @@ -221,6 +221,8 @@ export interface StageOptions { */ disabledFlashTip?: boolean; zoom?: number; + /** 画布 iframe 的 sandbox 属性值,不传 / false 表示不设置,见 @tmagic/stage 的 StageRenderConfig.iframeSandbox */ + iframeSandbox?: string | false; /** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */ beforeDblclick?: (event: MouseEvent) => Promise | boolean | void; } diff --git a/packages/stage/src/StageCore.ts b/packages/stage/src/StageCore.ts index 6d858a73..16456fd1 100644 --- a/packages/stage/src/StageCore.ts +++ b/packages/stage/src/StageCore.ts @@ -71,6 +71,7 @@ export default class StageCore extends EventEmitter { runtimeUrl: config.runtimeUrl, zoom: config.zoom, renderType: config.renderType, + iframeSandbox: config.iframeSandbox, customizedRender: async (): Promise => { if (this?.customizedRender) { return await this.customizedRender(this); diff --git a/packages/stage/src/StageRender.ts b/packages/stage/src/StageRender.ts index 9b24abad..717f121e 100644 --- a/packages/stage/src/StageRender.ts +++ b/packages/stage/src/StageRender.ts @@ -38,13 +38,21 @@ export default class StageRender extends EventEmitter { private runtimeUrl?: string; private zoom = DEFAULT_ZOOM; private renderType: RenderType; + private iframeSandbox?: string | false; private customizedRender?: () => Promise; - constructor({ runtimeUrl, zoom, customizedRender, renderType = RenderType.IFRAME }: StageRenderConfig) { + constructor({ + runtimeUrl, + zoom, + customizedRender, + renderType = RenderType.IFRAME, + iframeSandbox, + }: StageRenderConfig) { super(); this.renderType = renderType; this.runtimeUrl = runtimeUrl || ''; + this.iframeSandbox = iframeSandbox; this.customizedRender = customizedRender; this.setZoom(zoom); @@ -250,6 +258,11 @@ export default class StageRender extends EventEmitter { private createIframe(): HTMLIFrameElement { this.iframe = globalThis.document.createElement('iframe'); + // 沙箱由使用方配置(空字符串也是合法取值,表示放开全部限制),必须在设置 src 之前写入, + // 否则首次导航不受沙箱约束 + if (typeof this.iframeSandbox === 'string') { + this.iframe.setAttribute('sandbox', this.iframeSandbox); + } // 同源,直接加载 this.iframe.src = this.runtimeUrl && isSameDomain(this.runtimeUrl) ? this.runtimeUrl : ''; this.iframe.style.cssText = ` diff --git a/packages/stage/src/types.ts b/packages/stage/src/types.ts index 4e6f5d03..b99fdd0e 100644 --- a/packages/stage/src/types.ts +++ b/packages/stage/src/types.ts @@ -104,6 +104,8 @@ export interface StageCoreConfig { * 用于从图层树、面包屑等外部选中组件时,帮助用户快速定位组件在画布中的位置。 */ disabledFlashTip?: boolean; + /** 画布 iframe 的 sandbox 属性值,见 StageRenderConfig.iframeSandbox */ + iframeSandbox?: string | false; } export interface ActionManagerConfig { @@ -153,6 +155,17 @@ export interface StageRenderConfig { runtimeUrl?: string; zoom: number | undefined; renderType?: RenderType; + /** + * iframe 渲染时给画布 iframe 设置的 sandbox 属性值 + * + * - 不传 / `false`:不设置 sandbox 属性(默认,与未引入该配置前行为一致) + * - 字符串:原样写入 sandbox 属性,token 用空格分隔,如 + * `'allow-scripts allow-same-origin allow-forms allow-popups allow-downloads'` + * + * 注意:沙箱缺哪个 token,iframe 内对应能力就会静默失效(下载、弹窗、表单提交等), + * 编辑器自身需要 `allow-scripts` + `allow-same-origin`,否则画布不可用。 + */ + iframeSandbox?: string | false; customizedRender?: () => Promise; } diff --git a/packages/stage/tests/unit/StageCore.spec.ts b/packages/stage/tests/unit/StageCore.spec.ts index 26133389..c71b4787 100644 --- a/packages/stage/tests/unit/StageCore.spec.ts +++ b/packages/stage/tests/unit/StageCore.spec.ts @@ -100,6 +100,21 @@ describe('StageCore', () => { stage.destroy(); }); + test('iframeSandbox 透传到 iframe 的 sandbox 属性', async () => { + const host = globalThis.document.createElement('div'); + globalThis.document.body.appendChild(host); + const sandbox = 'allow-scripts allow-same-origin'; + const stage = new StageCore({ + renderType: RenderType.IFRAME, + runtimeUrl: '', + iframeSandbox: sandbox, + disabledRule: true, + }); + await stage.mount(host); + expect(host.querySelector('iframe')!.getAttribute('sandbox')).toBe(sandbox); + stage.destroy(); + }); + test('select 选中节点并触发 flash', async () => { const { host, page, stage } = createStage(); await stage.mount(host); diff --git a/packages/stage/tests/unit/StageRender.spec.ts b/packages/stage/tests/unit/StageRender.spec.ts index 2a31e4bd..ff468b6f 100644 --- a/packages/stage/tests/unit/StageRender.spec.ts +++ b/packages/stage/tests/unit/StageRender.spec.ts @@ -133,6 +133,47 @@ describe('StageRender - NATIVE', () => { renderer.destroy(); }); + test('未配置 iframeSandbox 时不设置 sandbox 属性', async () => { + const host = globalThis.document.createElement('div'); + globalThis.document.body.appendChild(host); + const renderer = new StageRender({ renderType: RenderType.IFRAME, runtimeUrl: '' }); + await renderer.mount(host); + + expect(host.querySelector('iframe')!.hasAttribute('sandbox')).toBe(false); + renderer.destroy(); + }); + + test('iframeSandbox 为 false 时不设置 sandbox 属性', async () => { + const host = globalThis.document.createElement('div'); + globalThis.document.body.appendChild(host); + const renderer = new StageRender({ renderType: RenderType.IFRAME, runtimeUrl: '', iframeSandbox: false }); + await renderer.mount(host); + + expect(host.querySelector('iframe')!.hasAttribute('sandbox')).toBe(false); + renderer.destroy(); + }); + + test('iframeSandbox 配置的 token 原样写入 sandbox 属性', async () => { + const host = globalThis.document.createElement('div'); + globalThis.document.body.appendChild(host); + const sandbox = 'allow-scripts allow-same-origin allow-downloads'; + const renderer = new StageRender({ renderType: RenderType.IFRAME, runtimeUrl: '', iframeSandbox: sandbox }); + await renderer.mount(host); + + expect(host.querySelector('iframe')!.getAttribute('sandbox')).toBe(sandbox); + renderer.destroy(); + }); + + test('空字符串 iframeSandbox 也写入 sandbox 属性(放开全部限制)', async () => { + const host = globalThis.document.createElement('div'); + globalThis.document.body.appendChild(host); + const renderer = new StageRender({ renderType: RenderType.IFRAME, runtimeUrl: '', iframeSandbox: '' }); + await renderer.mount(host); + + expect(host.querySelector('iframe')!.getAttribute('sandbox')).toBe(''); + renderer.destroy(); + }); + test('getElementImage 找到元素时调用 snapdom', async () => { const page = globalThis.document.createElement('div'); setIdToEl()(page, 'img-node'); diff --git a/playground/src/pages/Editor.vue b/playground/src/pages/Editor.vue index 5c8e2ee4..3bdf3d0b 100644 --- a/playground/src/pages/Editor.vue +++ b/playground/src/pages/Editor.vue @@ -5,6 +5,7 @@ ref="editor" :menu="menu" :runtime-url="runtimeUrl" + :iframe-sandbox="iframeSandbox" :props-configs="propsConfigs" :props-values="propsValues" :event-method-list="eventMethodList" @@ -83,6 +84,22 @@ uiService.set('propsPanelSize', 'default'); const datasourceList: DatasourceTypeOption[] = []; const runtimeUrl = `${VITE_RUNTIME_PATH}/playground/index.html`; +/** + * 画布 iframe 的 sandbox 配置(较宽松的演示值,非推荐值),演示「是否加、加哪些 token」由使用方决定: + * - 改成 `false` 即不设置 sandbox 属性(与未配置时一致) + * - 画布需要 allow-scripts + allow-same-origin(编辑器要读写 iframe 内的 DOM), + * allow-downloads 用于画布内组件的下载,其余 token 保留表单提交、弹窗、新窗口打开能力 + */ +const iframeSandbox = [ + 'allow-downloads', + 'allow-forms', + 'allow-modals', + 'allow-popups', + 'allow-popups-to-escape-sandbox', + 'allow-same-origin', + 'allow-scripts', +].join(' '); + const { propsValues, propsConfigs, eventMethodList, datasourceConfigs, datasourceValues, datasourceEventMethodList } = useEditorRes(); const { contentMenuData } = useEditorContentMenuData();