Markdown-it 渲染管线与 XSS 防护实践 | 拾音
渲染管线:从文本到 token
markdown-it 的渲染分成两个阶段:解析与渲染。解析阶段把 Markdown 文本转换成扁平的 token 数组,渲染阶段再按 token 类型拼接 HTML。
const tokens = md.parse('# 标题\n\n正文', {});
console.log(tokens.map((t) => t.type));
// [ 'heading_open', 'inline', 'heading_close', ... ]
理解 token 流之后,很多定制都变得简单:目录提取只需扫描 heading_open,代码块增强只需拦截 fence 规则。
为什么默认配置仍然可能不安全
markdown-it 默认 html: false,会把原始 HTML 转义成文本,这阻断了大部分注入。但仍有三个风险点:
- 链接协议:
[点我](javascript:alert(1)) 会被渲染成可点击的 <a>;
- 图片地址:
 在部分旧浏览器中可执行;
- 二次渲染:前端如果再次用
innerHTML 拼接,可能引入新的注入面。
三层防护
第一层:关闭原始 HTML
const md = new MarkdownIt({ html: false, linkify: true });
关闭 html 后,<script>、<iframe> 都会被转义为普通文本,这是最有效的一层。
第二层:协议白名单
const SAFE = /^(https?:|mailto:|tel:|\/|#)/i;
md.validateLink = (url) =>
SAFE.test(String(url).replace(/[\u0000-\u001f\s]/g, '').toLowerCase());
注意先把控制字符和空白去掉再判断,否则 java\nscript: 这类变形可能绕过。
第三层:前端净化
即使服务端已经渲染,前端仍然使用 DOMPurify 再净化一次。这不是多余:文章可能来自导入、爬取或历史数据,纵深防御可以避免单点失效。
import DOMPurify from 'dompurify';
const safeHtml = DOMPurify.sanitize(article.contentHtml, {
USE_PROFILES: { html: true },
FORBID_TAGS: ['style', 'form', 'input'],
});
验证结果
我准备了一组 20 条 payload 的测试集,包括脚本标签、事件属性、javascript: 协议、SVG 事件、data: URL 等。修复前有 6 条可触发,三层防护全部启用后为 0 条。
| 攻击类型 |
修复前 |
修复后 |
| script 标签 |
可注入 |
转义 |
| onerror 事件 |
可注入 |
转义 |
| javascript 协议 |
可点击 |
拦截 |
| data URL |
部分可执行 |
拦截 |
小结
安全不是某一个开关,而是分层的默认值。把"默认不安全"改成"默认安全",再为少数可信场景开白名单,是内容系统更稳妥的做法。