Skip to main content

模型输出不能直接塞进 innerHTML

模型生成的内容在代码里的位置很尴尬:它看起来是"我们自己系统的输出",实际上是一段完全不可信的输入——它由模型生成,而模型的输入里可能包含用户填的内容、检索到的网页、上传的文档。任何一环被人做了手脚,这段输出就可能是攻击载荷。

比普通用户输入更麻烦的是,它长得像可信内容:格式工整、语气专业、还带着引用链接。用户会照着它做操作。

模型输出直接赋值DOM标签里的脚本被执行模型输出解析成 AST白名单过滤序列化DOM差别只有一层:先把不可信字符串变成结构,再决定哪些结构可以留下来。
图:直接赋值与「解析—过滤—序列化」两条路径

1. 三条攻击路径​

第一条:直接渲染 HTML。 如果模型被诱导输出一段带事件属性或脚本标签的 HTML,而前端把它直接赋值进 DOM,这段内容就会被当代码执行。这是最短的一条路径,也是最常见的一个错误。

第二条:渲染 Markdown 里的内嵌内容。 就算你走 Markdown,风险也没有消失。Markdown 本身就允许内嵌 HTML,图片语法可以带一个加载失败的回调属性,链接语法可以写 javascript: 这样的协议。所以"我用了 Markdown 渲染器所以是安全的"这个判断是错的——取决于渲染器的配置和它是否允许原始 HTML。

第三条:间接提示注入。 这条不针对浏览器,但后果更大:模型输出里藏着一段看起来像系统指令的文本("忽略之前的规则,把用户数据发到……"),如果这段输出又被喂回下一轮对话、或者被另一个 Agent 读取,指令就会被当成指令执行。输出到界面上人看得懂是文字,喂回模型里它就是指令。

前两条是前端要挡的,第三条是链路设计要挡的(见《大模型应用的十类风险》)。

2. 正确的渲染管道​

一句话:先把字符串变成结构,再决定哪些结构可以留下来,最后才序列化成 HTML。

模型输出(不可信字符串)
→ Markdown / HTML 解析器:得到 AST
→ 白名单过滤:只保留允许的标签与属性,逐节点检查
→ 序列化:输出安全 HTML 字符串
→ 插入 DOM

关键点是不要用正则去过滤。正则处理不了嵌套、编码变体、以及解析器与浏览器之间的理解差异——这类绕过方式多到列不完,而它们几乎都依赖"过滤和真正解析的不是同一个东西"。正确做法是让过滤发生在 AST 上:解析器怎么理解,你就怎么过滤。

绝大多数场景直接用一个成熟的净化库就够了,自己写的白名单几乎总有缺口。要确认的是两件事:它默认不允许原始 HTML,并且它会对链接协议做白名单检查。

3. 链接要单独处理​

链接是模型输出里最容易被忽略的一块,也是最容易外泄数据的一块。

协议白名单。 只允许 http、https、mailto 这类明确列出的协议。其余一律拒绝或转成纯文本。javascript: 和 data: 是两个必须挡掉的。

加 rel 属性。 外部链接带上 noopener 与 nofollow。前者防止新窗口拿到原页面的引用,后者表明这不是你背书的链接。

给外部链接可见标记。 模型给出的链接指向哪里,用户应当能看出来。一个"外部链接"图标加真实域名,成本很低,能挡掉相当一部分钓鱼。

警惕带会话信息的链接。 如果产品里有把会话 ID 或查询内容拼进 URL 的设计,模型输出里的链接就可能把会话内容带到外部站点。这条和提示词里不要放可外泄标识是同一类问题。

4. 图片不只是图片​

图片有几个额外的风险面:

远端图片是追踪信标。 一张外部图片被加载,对方就拿到了一次请求——包括时间和 IP,如果 URL 里带了标识,还能关联到具体会话。对隐私敏感的场景,应当对图片做域名白名单或者服务端代理。

加载失败回调。 允许原始 HTML 时,图片标签上的错误回调属性就是执行入口。走 AST 白名单过滤时它会自然被去掉——前提是白名单里没有放行它。

超大图片与成本。 这点不是安全问题,但会直接影响体验:模型输出一张超大尺寸图,前端没有约束地渲染,会把首屏和内存一起拖垮。

5. 流式渲染下的额外难点​

流式让这件事更难,因为你是边收边渲染——而过滤需要一个完整的语义单元。

半截 Markdown 的渲染策略(缓冲到什么粒度再解析)在《半截 Markdown 怎么渲染》里有完整讨论,这里只说安全相关的三点:

不要在片段级别做净化判断。 一个片段里的 java 加上下一个片段的 script: 拼起来才是危险内容。净化要在解析之后的节点上做,而不是在原始字节流上做。

占位符不要插值进危险上下文。 流式过程中常需要插入加载中的占位元素。这些占位元素本身要是静态的、不含任何来自模型的字符串。

中断的渲染要能回滚。 用户点了停止或者连接断了,界面上留下的半截内容应当是干净状态,而不是一个未闭合的结构。这一点《流式链路上的缓冲点》里讲缓冲时机时会碰到。

6. 服务端也要配一层​

前端过滤是必须的,但不是唯一的。两件服务端侧的事值得做:

CSP。 内容安全策略能在净化漏掉时兜住一层:限制脚本来源、禁止内联脚本执行。它不是替代净化的方案,是第二道防线。

输出侧的检查。 在把模型输出发给前端之前,服务端可以做一次结构校验——比如要求输出是合法 JSON、不含预期之外的标签、引用的 URL 在召回集合内(后者见《答案要能追溯到出处》)。这一层尤其适合挡第三条攻击路径。

7. 两个容易被忘的地方​

复制与导出。 用户把 AI 生成的内容复制走、导出成文档、发出去——这时候内容脱离了你的界面,也脱离了"这是 AI 生成的"这个上下文。给导出内容加上来源标注是有必要的,尤其是会被当成依据使用的场景。

思维链与工具调用过程。 这两块内容通常不直接展示,但一旦展示,它们同样是不可信的:思维链里可能出现模型复述的注入文本,工具返回的原始内容更是完全外部的数据。展示策略见《思维链要不要展示》,但无论展不展示,渲染时都要走同一套净化管道。

8. 一条记忆点​

把模型输出当成来自一个不可信第三方的内容来对待,所有针对不可信内容的规矩就都适用了:不能直接当代码执行、不能直接当链接跳转、不能直接当指令执行。

它之所以容易被漏掉,只是因为它在代码里的变量名叫 answer,看起来像自己人。