为什么 Markdown 预览要做安全清洗

工具相关 ·

Markdown 的一个设计特点是允许直接内嵌 HTML。这个特性带来了灵活性——可以插入自定义标签、嵌入视频、做复杂排版。但它同时打开了一个风险入口:一段看起来正常的 Markdown 文本,可以包含 <script> 标签或者带事件属性的标签,一旦被渲染成 HTML 并插入页面,脚本就会在浏览器里执行。

风险从哪里来

Markdown 的解析结果是 HTML。如果这个 HTML 被直接插入页面(例如用 innerHTML),浏览器会把它当作正常的页面内容处理,包括执行其中的脚本。

攻击场景很具体。假设某个平台允许用户提交 Markdown 内容,另一个用户浏览了这条内容。如果平台在渲染时不做清洗,提交者就可以嵌入一段脚本,读取浏览者的 Cookie、伪造请求、或者篡改页面内容。这类攻击叫跨站脚本(XSS)。

即使用户只是把一段来路不明的 Markdown 粘贴到本地编辑器里预览,风险也存在——恶意内容可能利用脚本做本地操作,或者诱导用户点击。

几种常见的注入方式

注入不一定非要用 <script> 标签。以下几种方式都能触发脚本执行。

事件属性。像 <img src=x onerror=alert(1)> 这样的写法,图片加载失败时会触发 onerror,执行其中的代码。类似的事件属性还有 onload、onclick、onmouseover 等。

协议链接。<a href="javascript:alert(1)"> 这类链接,点击时会执行 javascript: 后面的代码。

内嵌框架。<iframe> 可以嵌入外部页面,用于钓鱼或加载恶意内容。

样式注入。某些 CSS 属性(如 expression,在旧版 IE 中)可以执行代码;即使不能执行,样式注入也可能用于界面伪装。

数据 URI。data: 协议的链接可以嵌入任意内容,包括 HTML 和脚本。

这些方式的共同点是:它们都不需要 <script> 标签,因此只过滤 <script> 的简单方案是不够的。

清洗是怎么做的

正确的做法是使用专门的 HTML 清洗库,而不是自己写正则过滤。

这类库的工作方式是:先解析 HTML 得到一个结构化的节点树,然后按白名单逐个检查每个节点。不在白名单里的标签被移除,不在白名单里的属性被移除,属性的取值如果包含危险协议也被移除。最后把清洗过的节点树重新序列化成 HTML。

使用白名单而不是黑名单是关键。黑名单要枚举所有危险的标签和属性,而新的攻击手法会不断出现,枚举必然遗漏;白名单只允许已知安全的标签和属性,未列出的默认拒绝,安全性更高。

具体到实现上,常见的流程是:先用 Markdown 解析器把源码解析成 HTML,再交给清洗库处理,最后插入页面。注意顺序——清洗必须在解析之后,因为危险内容可能在解析过程中才被还原出来(例如实体编码的标签)。

清洗库还会移除脚本标签、事件属性、以及 javascript: 这类协议链接。经过这一步,即使输入包含恶意内容,渲染结果也不会执行任何脚本。

导出时也要清洗

一个容易被忽略的点是:导出的 HTML 也需要清洗。

有些实现只在预览环节做了清洗,但导出时直接输出了原始的渲染结果。这会造成一个隐蔽的问题——预览时看起来是安全的(因为预览经过了清洗),但导出的文件里含有恶意内容,用户双击打开时脚本会执行。

所以正确的做法是让预览和导出共用同一套清洗流程,或者更准确地说,把清洗放在渲染管线的固定位置,让所有下游输出(预览、导出、复制)都经过它。

使用这类工具时的建议

第一,不要以为「本地工具就没有风险」。清洗做得好不好,与工具跑在哪里无关。粘贴来路不明的 Markdown 时,仍然应该保持警惕。

第二,导出的 HTML 分享给他人前,确认它经过了清洗。如果工具明确说明预览与导出都经过清洗,可以放心;如果没有说明,可以打开导出的文件查看源码,搜索有没有 <script> 或 onerror 之类的痕迹。

第三,不要把 Markdown 渲染结果直接用于服务端输出。前端的清洗只保护当前浏览者,如果内容要存储并展示给其他用户,服务端也必须做同样的清洗。前端清洗不能替代服务端清洗。

第四,理解清洗的代价。清洗会移除一些有用的标签和属性。如果确实需要嵌入自定义 HTML,可能需要调整白名单,但这会带来安全权衡,应当由了解风险的人决定。

检查清单

  • Markdown 允许内嵌 HTML,渲染结果中的脚本会在浏览器执行,存在 XSS 风险
  • 注入方式不止 <script>,事件属性、javascript: 链接、iframe 都能触发
  • 清洗应使用专门库并采用白名单策略,不要自己写正则过滤
  • 清洗必须在 Markdown 解析之后进行,顺序不能颠倒
  • 导出与复制也应经过同一套清洗流程,不能只保护预览
  • 前端清洗不能替代服务端清洗,面向多用户的内容两端都要处理
阅读 10