v-html + Markdown 转换器的隐形 XSS 炸弹,DOMPurify 一行堵死
在某后台管理系统里,文档中心模块用 mavon-editor 录入内容,用 showdown 把 Markdown 转成 HTML 后直接塞给 v-html 渲染。功能上完全正常,代码评审时也没人特别在意——Markdown 嘛,又不是让用户直接写 HTML。
直到安全扫描工具飘出一条警告:v-html is a potential XSS sink。手动测试把 <img src=x onerror=alert(1)> 粘进文档标题,保存后刷新页面,弹窗出来了。
现象
- Markdown 编辑器允许混入任意 HTML 标签
- 转换器(showdown / marked)的输出原样传给
v-html,无任何过滤 - 攻击者在富文本字段里写入
<script>或<img onerror=...>,每个阅读该文档的用户都会中招 v-html页面不报错,框架不给任何警告,症状完全隐藏在"正常渲染"里
根因
Vue 的 v-html(以及 React 的 dangerouslySetInnerHTML、原生的 innerHTML)完全绕过框架的 HTML 转义管道。框架默认把 {{ value }} 里的尖括号转成 < 防注入,但 v-html 明确告诉框架"我知道我在做什么,原样写入 DOM"。
Markdown 转换器的职责是"把 Markdown 语法转成 HTML 结构",它不负责安全——<img onerror=...> 是合法 HTML,转换器会原封不动地输出。把这两个"各自没问题"的东西直接拼在一起,结果是一个存储型 XSS 漏洞:内容写入数据库一次,每个渲染该内容的用户都自动执行攻击者的脚本。
Markdown 转换器只负责语法转换,不负责消毒。只要用户能编辑文档正文,就能嵌入任意 HTML payload。直接把转换结果传给 v-html = 把 XSS 防线交给了用户。
<!-- 危险:无 sanitize -->
<template>
<div v-html="markdownToHtml(doc.content)" />
</template>
<script>
import showdown from 'showdown'
const converter = new showdown.Converter()
export default {
methods: {
markdownToHtml(t) { return converter.makeHtml(t) } // 直出,无过滤
}
}
</script>正解
在转换器和 DOM 之间插一层 DOMPurify,把这个"转换 + 消毒"封进单一工具函数,所有 v-html 消费方统一从这里取,不允许绕路。
// src/utils/markdown.js — 全局唯一出口
import showdown from 'showdown'
import DOMPurify from 'dompurify'
const converter = new showdown.Converter({
tables: true,
tasklists: true,
strikethrough: true,
ghCodeBlocks: true,
simpleLineBreaks: true,
})
export function renderMarkdown(text) {
if (!text) return ''
return DOMPurify.sanitize(converter.makeHtml(text))
}<!-- 正确:走统一工具函数 -->
<template>
<div v-html="safeHtml" />
</template>
<script>
import { renderMarkdown } from '@/utils/markdown'
export default {
props: { content: String },
computed: {
safeHtml() { return renderMarkdown(this.content) }
}
}
</script>DOMPurify.sanitize() 会剥离 <script>、onclick、onerror 等危险属性,只保留白名单内的安全标签和属性。只要所有 v-html 都走 renderMarkdown(),XSS 路径就被从源头切断。React 同理:dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}。
排查:找出所有绕路点
修完之后别忘了扫全量代码,确认没有其他地方直接用 v-html 连着裸转换器:
grep -rn "v-html\|dangerouslySetInnerHTML\|innerHTML\s*=" src/ \
| grep -v utils/markdown有输出就说明还有漏网的消费点,逐一改掉。
浏览器端 DOMPurify 是最后一道防线,但如果内容会被索引、导出、或通过 API 分发给其他客户端,建议在保存时也在服务端做一次消毒(Node 用 isomorphic-dompurify,PHP/Java 用对应的 HTML Purifier)。输入侧 + 输出侧双重防御,纵深更稳。
一句话外卖
v-html是框架级的"免责声明"——只要用它,就必须自己保证传入的字符串已经是安全的;Markdown 转换器的输出永远不是安全的,中间必须过 DOMPurify。