feat(editor): 支持配置画布 iframe 的 sandbox 属性

新增 iframeSandbox 配置,透传链路为 EditorProps -> StageOptions ->
StageCore -> StageRender,在 createIframe 中设置 src 之前写入 sandbox
属性。默认不设置该属性,保持存量行为不变。

- stage: StageRenderConfig / StageCoreConfig 新增 iframeSandbox 类型
- editor: EditorProps / StageOptions 新增 iframeSandbox 并透传
- docs: 补充 iframeSandbox 说明与 srcdoc、同源限制的注意事项
- test: 覆盖未配置 / false / 空字符串 / 多 token 与 StageCore 透传
This commit is contained in:
roymondchen 2026-10-27 16:12:45 +08:00
parent d017f43573
commit 7e8cb1f9db
11 changed files with 141 additions and 1 deletions

View File

@ -503,6 +503,32 @@ const renderFunction = async (stage) => {
</template>
```
## 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
<template>
<m-editor iframe-sandbox="allow-scripts allow-same-origin allow-downloads"></m-editor>
</template>
```
## autoScrollIntoView
- **详情:**

View File

@ -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,

View File

@ -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 */

View File

@ -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;

View File

@ -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> | boolean | void;
}

View File

@ -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<HTMLElement | null | void> => {
if (this?.customizedRender) {
return await this.customizedRender(this);

View File

@ -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<HTMLElement | null | void>;
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 = `

View File

@ -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<HTMLElement | null | void>;
}

View File

@ -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);

View File

@ -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');

View File

@ -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();