返回博客

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 }} 里的尖括号转成 &lt; 防注入,但 v-html 明确告诉框架"我知道我在做什么,原样写入 DOM"。

Markdown 转换器的职责是"把 Markdown 语法转成 HTML 结构",它不负责安全——<img onerror=...> 是合法 HTML,转换器会原封不动地输出。把这两个"各自没问题"的东西直接拼在一起,结果是一个存储型 XSS 漏洞:内容写入数据库一次,每个渲染该内容的用户都自动执行攻击者的脚本。

坑:converter.makeHtml() 的输出不是安全字符串

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() 包在转换器之后,集中管理

DOMPurify.sanitize() 会剥离 <script>onclickonerror 等危险属性,只保留白名单内的安全标签和属性。只要所有 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。