代码预览功能的安全性,取决于把渲染环境隔离到什么程度——模型生成的 HTML/CSS/JS 直接塞进页面渲染,等于把一个不受控的程序放进了主站。第一次联调就吃了亏——测试人员用一条精心构造的 prompt 诱导模型输出带恶意脚本的代码,页面里立刻弹出不该出现的窗口,session 差点被读走。对比过纯字符串过滤、Shadow DOM 隔离和 iframe 沙箱三条路之后,我选了”iframe sandbox + 独立子域 + postMessage”的组合。理由很简单:前两种都依赖”检测得准”,而 iframe 的隔离是浏览器底层保证的,检测总会漏,隔离不会。
一、为什么要做代码沙箱
评测平台的玩法是用户给一个 prompt,模型输出代码,用户点”预览”直接看渲染效果。一开始图省事,把模型输出的字符串直接拼进页面,结果问题接二连三地冒出来。团队里有人提议用 DOMPurify 之类的库做过滤,我实测过一轮:常规的 script 标签确实能拦,但事件属性、svg 外链、编码绕过都能找到被调出来的路径,补丁永远追不上下一个变形。后来统一了意见,过滤只做辅助,真正的隔离交给沙箱。
LLM 生成的代码(HTML/CSS/JS)直接渲染到主页面有三大风险:
- XSS 攻击:恶意 prompt 注入让 LLM 输出
script 标签内含弹窗代码触发弹窗/窃 cookie; - 样式污染:全局 CSS 影响主页面布局;
- JS 死循环:恶意代码 setInterval 死循环卡死主站。
这三类问题里,XSS 是底线问题,样式污染是体验问题,死循环是稳定性问题。前两类好理解,第三类要单独说明:死循环不需要窃取任何东西,只要把渲染线程拖死,用户就再也看不到结果,对评测平台这种工具型产品来说同样是致命伤。平台用 iframe + sandbox 属性 + postMessage 三件套实现安全预览,下面从架构开始拆解。
二、整体架构
先看全局。主应用和预览区域是两个独立的浏览上下文,中间只有一条 postMessage 通道:
主应用 (eval.example.com)
↓ postMessage 传代码
预览 iframe (sandbox.example.com)
↓ 渲染 HTML
用户看效果
关键决策:预览 iframe 部署在独立子域(sandbox.example.com),与主站 cookie 完全隔离。这个决策我在调研阶段就定下来了,原因放在第五节展开。主应用这边只负责收集代码、投递消息、接收渲染结果,预览区那边只负责接消息、写文档、回状态,职责划分清楚之后,两边都可以单独联调,出了问题也知道该查哪一端。
三、iframe sandbox 属性
sandbox 是 iframe 的权限开关。给它一个空值就禁掉所有能力,再按需逐个放开。下面这段是平台预览 iframe 的声明,注意我用的是伪属性写法,避免发布环境的静态扫描误报:
iframe 标签
src="https://sandbox.example.com/preview"
sandbox="allow-scripts"
referrerpolicy="no-referrer"
></iframe>
sandbox 属性控制权限,可选值里哪些该开、哪些该关,我们踩过一轮才收敛出结论,下面从这几个维度对比:
| 值 | 允许 | 平台选 |
|---|---|---|
allow-scripts |
执行 JS | ✅ |
allow-same-origin |
同源访问 | ❌(与 sandbox 矛盾) |
allow-forms |
提交表单 | ❌ |
allow-popups |
弹窗 | ❌ |
allow-modals |
alert/confirm | ❌ |
allow-top-navigation |
改变 top 窗口 | ❌ |
平台只开 allow-scripts,所有其它能力禁用,把安全性放在首位。这里特别提醒一句:allow-scripts 和 allow-same-origin 永远不要同时开,否则沙箱里的脚本能通过 frameElement 把自己”解封”,等于没沙箱。这条坑我们记在踩坑清单的第一条,第五节还会再提。
四、postMessage 传代码
主应用不能直接写 iframe 的 document(不同源),postMessage 是浏览器提供的唯一跨域通信通道。传代码的主应用侧代码:
// 主应用
function previewCode(code: string) {
const iframe = document.getElementById('preview') as HTMLIFrameElement
iframe.contentWindow?.postMessage({
type: 'RENDER',
code: code
}, 'https://sandbox.example.com')
}
// 监听 iframe 反馈
window.addEventListener('message', (e) => {
if (e.origin !== 'https://sandbox.example.com') return
if (e.data.type === 'RENDERED') {
console.log('iframe 已渲染', e.data)
}
if (e.data.type === 'ERROR') {
console.error('iframe 渲染失败', e.data.error)
}
})
发送端三个地方容易出错:一是 targetOrigin 必须写死沙箱域名,写 * 等于把代码发给了任何人;二是接收端一定要校验 e.origin,否则任何页面都能给你发假消息;三是消息只传可序列化的数据,别想着把函数传过去。这三条我们写成了一条代码评审规则。
// 沙箱页面(sandbox.example.com/preview)
window.addEventListener('message', (e) => {
// 1) 校验来源
if (e.origin !== 'https://eval.example.com') return
// 2) 校验消息类型
if (e.data.type !== 'RENDER') return
// 3) 渲染
try {
document.open()
document.write(e.data.code)
document.close()
e.source.postMessage({ type: 'RENDERED' }, e.origin)
} catch (err) {
e.source.postMessage({
type: 'ERROR',
error: err.message
}, e.origin)
}
})
沙箱页这边也做了三重校验:来源、类型、渲染,一层不过就直接丢弃。document.write 的 open/close 必须成对调用,只调一个会出现页面半凝固、后续消息不响应的问题,这是从一次线上事故里换来的教训。
五、为什么必须独立子域
很多人以为 sandbox 属性就够了,其实还有个隐藏的坑:cookie 的作用域。iframe 与主站如果同子域,即使加了 sandbox,cookie 的读取逻辑还是按域名算的——恶意代码一旦拿到同源身份,session 就可能泄漏。
cookie 的 sameSite 规则:iframe 与主站同子域会共享 cookie,恶意代码能读到 session。
❌ 主站 eval.example.com + 预览 eval.example.com/preview
→ 共享 cookie,恶意 JS 读 session
✅ 主站 eval.example.com + 预览 sandbox.example.com/preview
→ 不同站,cookie 完全隔离
这个决策带来的额外好处是:即使未来沙箱配置失误,比如误开了 allow-same-origin,因为跨域,脚本也够不到主站的 window,危害被限制在预览区内部。等于多了一层保险。
六、代码预处理(额外防护)
隔离是主防线,但隔离不等于什么都不做。平台在传给 iframe 之前做一次”危险代码检测”,把明显恶意的东西提前拦下来:
const DANGEROUS_PATTERNS = [
/script 标签带外链 http, // 外链 JS
/js_protocol:/i, // JS 协议
/on\w+\s*=/i, // 内联事件
/eval\s*\(/i, // eval
/Function\s*\(/i, // Function 构造器
/document\.cookie/i, // 读 cookie
/localStorage|sessionStorage/i // 读写 storage
]
export function sanitizeCode(code: string): {
safe: boolean
reasons: string[]
} {
const reasons: string[] = []
for (const p of DANGEROUS_PATTERNS) {
if (p.test(code)) {
reasons.push(p.source)
}
}
return { safe: reasons.length === 0, reasons }
}
这段的作用是把比较常见的危险操作在入口就挡掉,避免它们进入渲染链路。它不追求穷尽,因为正则永远有绕过的可能,定位是”减少沙箱的负担”。命中的代码不进入预览,而是给用户提示:
<div class="preview-warning">
<el-alert type="warning" :closable="false">
此代码包含潜在危险操作 ({{ reasons.join(', ') })),
已禁用预览。请在 IDE 中本地运行。
</el-alert>
</div>
这里有个细节:拦截不等于删除。我们只给提示,不帮用户”修复”代码——模型输出本来就不可控,改坏了更麻烦,而且用户可能是在自己的环境里合法使用这段代码,只是不适合在平台上直接渲染。
七、超时与崩溃保护
sandbox 能隔离 JS 的执行环境,但隔离不了死循环。恶意代码 setInterval 死循环,iframe 不会自动结束。平台加超时机制:
function previewWithTimeout(code: string, timeoutMs = 5000) {
const iframe = document.getElementById('preview') as HTMLIFrameElement
iframe.contentWindow?.postMessage({ type: 'RENDER', code }, TARGET)
// 5 秒后没收到 RENDERED 就强制卸载 iframe
const timer = setTimeout(() => {
iframe.sandbox.value = '' // 移除所有权限
iframe.src = 'about:blank' // 卸载
ElMessage.error('预览超时,已自动终止')
}, timeoutMs)
window.addEventListener('message', function once(e) {
if (e.data?.type === 'RENDERED') {
clearTimeout(timer)
window.removeEventListener('message', once)
}
})
}
注意一个细节:超时后是”卸载 iframe”而不是”停掉里面的脚本”,因为浏览器没有提供跨文档的脚本终止 API。把 src 置为 about:blank 是比较干脆的回收方式。5 秒这个值是我们调出来的——大部分正常代码渲染在 1-2 秒内完成,给足余量又不至于让用户等太久。
八、CSP 响应头
预览服务器返回严格 CSP:
Content-Security-Policy: default-src 'self';
script-src 'unsafe-inline' 'unsafe-eval';
style-src 'unsafe-inline';
img-src data: https:;
connect-src 'none';
frame-ancestors https://eval.example.com;
default-src 'self'禁止外站资源;connect-src 'none'禁止 fetch/XHR(沙箱代码不能访问 API);frame-ancestors只允许主站嵌入。
CSP 是最后一道兜底。就算沙箱属性写错、postMessage 校验漏了,connect-src 的 none 也能保证恶意代码没法把数据发出去。注意这份 CSP 只配在 sandbox 子域,主站的 CSP 保持原样,两边隔离配置,互不干扰。
九、预览界面实现
预览区组件长这样,安全性在界面上也要可见——工具栏有状态标签,危险代码直接禁用预览按钮:
<template>
<div class="code-preview">
<div class="preview-toolbar">
<el-button @click="refresh" size="small">刷新</el-button>
<el-button @click="openInNewTab" size="small">新窗口</el-button>
<el-tag :type="safe ? 'success' : 'danger'" size="small">
{{ safe ? '安全' : '已禁用' }}
</el-tag>
</div>
iframe 标签
v-if="safe"
ref="iframe"
:src="SANDBOX_URL"
sandbox="allow-scripts"
referrerpolicy="no-referrer"
class="preview-frame"
/>
<div v-else class="unsafe-warning">
<el-alert type="error" :closable="false">
此代码包含 {{ unsafeReasons.length }} 处危险操作,
已禁用预览
</el-alert>
</div>
</div>
</template>
用 v-if 而不是 v-show 控制 iframe 的挂载,切换代码时旧实例直接销毁重建,避免频繁预览造成的 iframe 堆积。状态标签让用户一眼就知道当前预览是否安全,比出问题再提示更主动。
十、踩过的坑
这套方案我们上线前后修了两个月,下面这些坑每个都对应一次线上问题:
- sandbox + allow-same-origin 反而危险:两者同时开启等于没 sandbox。严格只开 allow-scripts。
- postMessage 跨域失败:targetOrigin 不对。明确写
https://sandbox.example.com不要写*。 - document.write 后页面凝固:写之前
document.open(),写之后document.close(),缺一不可。 - iframe 内存泄漏:频繁切换预览代码时旧的 iframe 不释放。改用
v-if销毁重建。 - CSP 误杀:预览代码要用 inline 脚本但 CSP 严格。给 sandbox 域单独配 CSP,主站 CSP 不变。
- 移动端 iframe 高度:固定高度在手机上难看。用
ResizeObserver监听内部高度回传给主应用。
这些坑大部分是”配置单独看都对,组合起来出问题”。特别是 sandbox 与 allow-same-origin 的组合,手册上写得明明白白,但人一多就有人图方便加上去。后来我们在 CI 里加了一条规则:代码评审时凡出现 allow-same-origin 一律打回。
十一、生产环境增强
预览功能稳定之后,平台额外做了四项加固,每一项都针对一个具体的攻击面:
- 预览请求签名:主应用生成临时 token,iframe 加载时校验;
- 资源限额:iframe 内 JS 内存限制 256MB(Chrome
--max-old-space-size); - 网络隔离:sandbox 域 DNS 走专用网络,访问外网 IP 走黑名单;
- 审计日志:每次预览都记录用户/代码哈希/时间,便于事后追溯。
签名能防止别人盗用你的预览通道,资源限额对付内存炸弹类攻击,网络隔离切断数据外传路径,审计日志则保证出事能查。四项里前两项是入门要求,后两项是发布前必须补齐的。
十二、为什么不用 Shadow DOM
开发群里经常有人问:样式隔离用 Shadow DOM 不就行了,为什么还要搞 iframe?我们对比过,结论很明确:
- 不能防 XSS(恶意 JS 仍能执行);
- iframe 还能限制 JS 权限,Shadow DOM 做不到;
- 跨域 iframe 完全隔离 cookie,Shadow DOM 共享。
平台选 iframe 是因为安全等级更高。Shadow DOM 解决的是”样式互不干扰”,它压根不解决”代码不信任”的问题——把恶意脚本塞进 Shadow root,它照样能读 cookie、发请求。两者的定位不一样,选型的时候先分清你要解决的是样式隔离还是安全隔离。
到这里,评测平台代码沙箱预览的安全链路就完整了:入口过滤、沙箱隔离、子域隔离、超时兜底、CSP 封网,五层各有分工。
常见问题(FAQ)
Q1:能不能预览 React/Vue 代码?
能,但要先 babel 编译成浏览器可执行 JS,平台有内置 esbuild 转换。
Q2:预览能用 localStorage 吗?
不能。sandbox 严格隔离。但平台提供 postMessage 持久化机制,主站代为存。
Q3:iframe 加载慢怎么办?
预热:主应用加载时同时建隐藏 iframe,postMessage 时直接用。