一个 Markdown 转换器可以相信什么

发布于 2026-11-03 · 最近复核 2026-11-06

Markdown 看起来像纯文本,所以大家都以为转换器是个无害的小东西。它不是:它是「别人写的字」变成「浏览器会照做的标记」的那一步。这一步安不安全,由两个决定说了算。第一个是读的是哪套方言——同一串字符在 GFM 与 CommonMark 里意思不同。第二个是输出里允许出现什么,而那是策略问题,不是解析问题。

这一篇把两件事都讲清楚,也讲那个让转换器「可以被检查」而不只是「被相信」的办法:它把文档写回去、再读一遍。本站的每一次转换都会跑这个检查,而一旦文字真的变了,页面会指出变在哪里。

一个模型、两个解析器、两个写出器

转换器中间只有一份文档模型:Markdown 解析进它,HTML 也解析进它,然后两个写出器把它变回其中一种语言。这就是同一个页面能同时给出两个方向、却不至于变成两台不相干工具的原因;也是审计表无论从哪个方向读进来、描述的都是同一份文档的原因——上面那份样本是 1 个标题、1 个链接、1 张表,在模型里数一次,而不是在两套实现里各数一次。

模型也是「诚实」住的地方。Markdown 表达不了的元素,在读 HTML 的时候就被记成损失,而不是悄悄拆掉;引擎不实现的语法——脚注、定义列表、数学、提醒式引用——被当成它将来会变成的文字报出来,于是它永远不会悄悄变成别的意思。

GFM 与 CommonMark 在哪里不一致

CommonMark 固定的是 Markdown 一直就有的那套语法;GFM 在它上面加了四样东西,而一个不知道这四样被打开了的读取方,会把它们当标点印出来。还是上面那份样本:GFM 给出 1 张真正的表,CommonMark 给出 0 张——竖线表退化成一段文字,竖线又变回内容。这个差别只需要表单上的一个下拉,因为它是读取方的属性,不是文档的属性。

构造CommonMarkGFM
竖线表带竖线的文字一张能对齐的表
任务列表以方括号开头的列表项一个禁用的复选框
删除线字面的波浪号一个 del 元素
裸链接碰巧长得像网址的文字一个链接
缩进代码四个空格开始一个代码块本引擎公开的偏离:不这样

往返,是转换器检查自己的办法

每一次转换都会把文档重新写成 Markdown,再用同一个解析器读一遍,然后比较文字——空白合并之后再比,不比结构。这个选择就是重点:结构可以换写法,因为 _强调_ 可能被写成 *强调*,下划线式标题可能回来时变成井号式;但词一个都不许变。上面那份样本读回来是一致的,而如果不一致,页面会印出两处第一次分岔的位置。

这个检查说的是转换器,不是访客。结果是假,就意味着本仓库某处的转义出了问题,页面上的那句话会这么说。另一个方向也一样:HTML 写成 Markdown、再解析回来、再比较,所以一张被重新排过的表行或列表项,会以「变了几行」的形式出现,而不是悄悄改掉。

信任边界:转义、协议与图片

别人写的 Markdown 是不可信输入,而转换器正是「不可信输入变成标记」的那一步,所以默认值一律取保守的那一侧。原始 HTML 默认被转义,除非访客明确要求放行:<span class="badge"> 送出来的是标记,读者能看见,浏览器不能执行。能执行代码的链接被拒绝,行号印出来、文字留下来——javascript:alert(1) 只留下 1 条问题与一句没有链接的话;而在同一个页面上把开关关掉,目的地会按原样保留(kept)。

判断是在解码、去空白之后的地址上做的,因为浏览器就是这么读的:JaVaScRiPt:、javascript:、以及协议中间夹一个制表符,都会走到同一个处理器。图片与链接不是同一个问题,所以 data:image/... 可以带着像素进来,而本该是图片的地方出现一个 data: 文档时会被拒绝。页面自己也从不把结果显示出来:预览意味着把别人的标记放进这个页面,而那正是上面那些转义要避免的事。

这些数字从哪里来

这一篇没有说什么

它不声称自己是 CommonMark 实现:引擎实现的是一套被点名的子集,页面把这套子集印出来,而不是暗示其余的部分也有。它也不讲事后清洗 HTML——这里的转义发生在读文档的时候,和一个跑在成品页面上的清洗器是两层东西。它不能告诉你某个具体站点会渲染成什么样:一个宿主可能剥掉表格、改写链接或者拒绝内联图片,而这些从这个页面上一个都看不见。至于引擎不实现的语法——脚注、定义列表、数学——它只说它们将来会变成什么文字,除此之外什么也不承诺。

到 Markdown 转 HTML里转一份;想整理输出,用HTML 格式化与压缩器; 想知道文档到底说了多少字,用字数与字符统计。

以上只是解释——不构成对任何具体站点的安全评审。 阅读完整免责声明。