代码沙箱预览功能实操方法(AI 大模型评测平台的安全代码展示)

代码预览功能的安全性,取决于把渲染环境隔离到什么程度——模型生成的 HTML/CSS/JS 直接塞进页面渲染,等于把一个不受控的程序放进了主站。第一次联调就吃了亏——测试人员用一条精心构造的 prompt 诱导模型输出带恶意脚本的代码,页面里立刻弹出不该出现的窗口,session 差点被读走。对比过纯字符串过滤、Shadow DOM 隔离和 iframe 沙箱三条路之后,我选了”iframe sandbox + 独立子域 + postMessage”的组合。理由很简单:前两种都依赖”检测得准”,而 iframe 的隔离是浏览器底层保证的,检测总会漏,隔离不会。

一、为什么要做代码沙箱

评测平台的玩法是用户给一个 prompt,模型输出代码,用户点”预览”直接看渲染效果。一开始图省事,把模型输出的字符串直接拼进页面,结果问题接二连三地冒出来。团队里有人提议用 DOMPurify 之类的库做过滤,我实测过一轮:常规的 script 标签确实能拦,但事件属性、svg 外链、编码绕过都能找到被调出来的路径,补丁永远追不上下一个变形。后来统一了意见,过滤只做辅助,真正的隔离交给沙箱。

LLM 生成的代码(HTML/CSS/JS)直接渲染到主页面有三大风险:

  1. XSS 攻击:恶意 prompt 注入让 LLM 输出 script 标签内含弹窗代码 触发弹窗/窃 cookie;
  2. 样式污染:全局 CSS 影响主页面布局;
  3. 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 一律打回。

十一、生产环境增强

预览功能稳定之后,平台额外做了四项加固,每一项都针对一个具体的攻击面:

  1. 预览请求签名:主应用生成临时 token,iframe 加载时校验;
  2. 资源限额:iframe 内 JS 内存限制 256MB(Chrome --max-old-space-size);
  3. 网络隔离:sandbox 域 DNS 走专用网络,访问外网 IP 走黑名单;
  4. 审计日志:每次预览都记录用户/代码哈希/时间,便于事后追溯。

签名能防止别人盗用你的预览通道,资源限额对付内存炸弹类攻击,网络隔离切断数据外传路径,审计日志则保证出事能查。四项里前两项是入门要求,后两项是发布前必须补齐的。

十二、为什么不用 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 时直接用。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
其AI的头像其AI普通用户

相关推荐

返回顶部