直接说重点:AI返回的Markdown,你绝对不能 v-html 或者 dangerouslySetInnerHTML 直接怼上去。 哪怕内容是你自家模型生成的。
为什么模型输出也得防
很多人觉得"又不是用户输入,模型还能害我?"——会的。
我遇到过两类真实情况:
- 用户在prompt里塞了
<img src=x onerror=alert(document.cookie)>,让模型"复述一下我刚说的"。模型很乖,原样吐回来。你不过滤,XSS当场触发。 - RAG场景下,知识库文档本身就含恶意片段,模型检索到了照抄。
模型不是过滤器,它只是个复读机(有时候)。渲染层才是你最后一道防线。
我的方案:marked + DOMPurify
import { marked } from 'marked';
import DOMPurify from 'dompurify';
export function renderMarkdown(md: string): string {
// 1. Markdown -> HTML
const rawHtml = marked.parse(md, { breaks: true, gfm: true }) as string;
// 2. 净化
const clean = DOMPurify.sanitize(rawHtml, {
ALLOWED_TAGS: [
'p', 'br', 'strong', 'em', 'code', 'pre',
'ul', 'ol', 'li', 'blockquote', 'a',
'h1', 'h2', 'h3', 'table', 'thead', 'tbody', 'tr', 'th', 'td',
],
ALLOWED_ATTR: ['href', 'class'],
});
return clean;
}
顺序很关键:先转HTML再净化,不是反过来。DOMPurify要在真正的HTML上工作。
链接是重灾区
<a href="javascript:alert(1)">这种点击型XSS,DOMPurify默认会拦javascript:协议。但我还是手动加了一层钩子,顺便给所有外链补上rel:
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'A') {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer nofollow');
}
});
noopener是防新标签页通过window.opener反向操控你原页面——这个坑很多人不知道,光加target=_blank等于开了个后门。
流式渲染的额外麻烦(这个最坑)
AI是一个token一个token吐的。意味着你某一帧可能拿到的是:
这是一个未闭合的 **加粗
marked解析这种半截语法会产生奇怪结果,比如把整段后面都吃成加粗。我一开始每来一个token就重新marked.parse整个字符串,CPU直接拉满,而且渲染抖动。
后两个优化救了我:
- 节流:用
requestAnimationFrame攒着,一帧最多渲一次,别每个token都重渲。 - 代码块兜底:流式没收完的代码块(
```还没配对),我先当纯文本展示,等检测到闭合再交给marked。
let pending = '';
let scheduled = false;
function onToken(t: string) {
pending += t;
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
container.innerHTML = renderMarkdown(pending);
scheduled = false;
});
}
一个我没解决得很漂亮的点
代码高亮。我用了highlight.js,但流式下代码块一直在变,高亮要反复跑,长代码块会卡。我最后偷懒:只在 [DONE] 之后做一次高亮,流式过程中代码块就素颜展示。体验上有点跳,但性能稳,算个取舍。
最近我把这套渲染逻辑复用到一个新demo上——后端是我在一个拖拽配节点搭智能体的工具里搭的问答流,零代码配完直接给了我个接口。省下来的时间全花在打磨这个Markdown渲染器上了,值。
如果你后端不想自己折腾模型接入,讯飞的MaaS可以直接调现成模型,前端专心做安全渲染就行。你们防AImarkdown XSS还有什么野路子?评论区交个底。