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