C# 里把 Markdown 转 HTML,绝大多数项目用 Markdig 就够了,Markdown.ToHtml(md) 一行出结果。但只要你渲染的是别人提交的 Markdown,这一行就是不及格的——官方 usage 文档自己写着:Markdig is a Markdown processor, not an HTML sanitizer。转换完还得再过一道清洗,否则等于给页面开了任意 HTML 注入口。
大家通常怎么做
接这个需求的人,路径基本就三条。
第一条,NuGet 装 Markdig,写一行 Markdown.ToHtml(markdown)。第二条,老项目里翻出 MarkdownSharp 或 CommonMark.NET,照着十几年前的写法改改。第三条最野:自己拿正则替换,# 换 <h1>、** 换 <strong>,写个百来行的 static 方法,跑通 README 例子就上线。
这三条的共同点是:把「渲染出来看着对」当成了做完了。
我在做发票那套活时踩过一模一样的坑——就像发票识别那篇里写的:识别只是第一步,结构化才是灵魂。转换也一样,把 ** 变成 <strong> 只是第一步,决定什么东西不许出现在输出里才是灵魂。
为什么这条路走不通
问题不在渲染质量,在信任边界。Markdown 语法天生允许内嵌原始 HTML,你给它这么一段:
这是一段正常文字
<img src=x onerror=alert(document.cookie)>
还有 [点我](javascript:alert(1)) 这个链接。 默认管道会把 <img> 原样写进输出,onerror 一个字符不少;javascript: 协议也照收。存进库再渲染给别人看,就是一个标准存储型 XSS。
官方 usage 文档的原话大意是:Markdig 是 Markdown 处理器,不是 HTML 清洗器;关闭 HTML 解析只是降低原生 HTML 输入的风险,本身不等于安全;接收用户提供的 Markdown,就要清洗生成的 HTML,并过滤或重写链接与图片 URL。
所以 DisableHtml() 是减压阀,不是防火墙。关掉标签块,javascript: 链接还在。
这让我想起写 C# 采集器时记的一条:导出的 CSV 用 Excel 打开全是乱码,因为 Excel 默认按 ANSI 读,而我们写的是 UTF-8。那篇文章里还有一句我挺认同——抓包、模拟请求、JSON 解析这些基本功大家都懂,真正拉开差距的是异常处理、重试策略这些细节。转换同理:拉开差距的是你有没有想清楚输出要交给谁。
换个思路
别把它当一次「转换」,当一条四段管线:
解析(parse) → 渲染(render) → 清洗(sanitize) → 落地 第一段定语法范围,第二段产出 HTML 字符串,第三段才是安全边界,第四段决定它落到哪。
我原本以为清洗是可选优化项,后来才发现它该是默认项——因为内容源会变。第一版只有运营在后台写文档,我判断是可信输入;半年后接了个投稿入口,同一个渲染方法被复用,没人记得回去补清洗。
还有个中文场景专属的坑,中文教程几乎不提:CommonMark 规范对 CJK 两侧强调符号的处理有争议,这是*重点*内容 这种写法默认不解析成 <em>,页面原样挂着两个星号。官方 README 专门列了 CJK-friendly Emphasis 扩展(致谢 tats-u 的 markdown-cjk-friendly 项目)。做中文站,这个基本必开。
具体做法
dotnet add package Markdig
dotnet add package HtmlSanitizer 管道必须做成静态单例。官方写得很明确:MarkdownPipeline 线程安全、应该共享,别每次调用都新建,构建管道有成本(扩展装配、解析器分配):
// 对:建一次,到处用
private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions() // 表格、脚注、任务列表等
.UseAutoLinks()
.UseCjkEmphasis() // 中文强调,中文场景必开
.UseGenericAttributes() // 注意:这个要放最后
.Build();
public static string Render(string md)
=> Markdown.ToHtml(md ?? string.Empty, Pipeline); 顺序不是玄学。UseGenericAttributes() 会去改别的 parser 来支持 {.class #id} 写法,官方要求它放在最后;几个都往强调字符上加规则的扩展放一起,也可能互相打架。
管道要从配置文件读,就用官方的字符串式配置:
var pipeline = new MarkdownPipelineBuilder()
.Configure("advanced+cjk-friendly-emphasis+nohtml+nofollowlinks")
.Build(); 常见 token 有 common / advanced / pipetables / gfm-pipetables / tasklists / autoidentifiers / nohtml / cjk-friendly-emphasis,完整表在官方文档。
最后一步,过清洗(HtmlSanitizer / Ganss.XSS):
private static readonly HtmlSanitizer Sanitizer = new();
public static string RenderSafe(string md)
{
var raw = Markdown.ToHtml(md ?? string.Empty, Pipeline);
return Sanitizer.Sanitize(raw);
} 老项目注意:官方 README 写明从 0.20.0 起 Markdig 只兼容 netstandard2.0 / netstandard2.1 / netcoreapp2.1 / 3.1,要跑 .NET Framework 3.5 或 4.0 得退到 0.18.3。4.8 支持 netstandard2.0,用当前版本没问题。版本号请以仓库最新说明为准,许可证 BSD-2-Clause。
效果对比
| 方案 | GFM 表格 | 中文强调 | 原始 HTML | 不可信输入 |
|---|---|---|---|---|
| 自己写正则 | 基本没有 | 修不对 | 整段漏出 | 无防护 |
| MarkdownSharp 一类 | 不支持 | 不支持 | 行为不定 | 无防护 |
| Markdig + 清洗 | 内置 20+ 扩展 | 开 cjk-friendly-emphasis | nohtml + 清洗 | 清洗后可用 |
性能上泼点冷水:README 那份 benchmark 比的是很老的版本(0.5.x 对 CommonMark.NET 0.11.0 那一代),那是历史数据,别抄进方案书当今天的依据,真要论证就自己拿实际文档在本地跑 BenchmarkDotNet。
边界条件也说清楚:内容 100% 由内部可信人员产出、渲染入口不会新增外部投稿,才可以不做清洗。只要有一条不满足就加上,成本就是一行 Sanitize()。
常见问题
我们的 Markdown 只有运营在后台写,也要过清洗吗?
建议加。不是不信任运营,是内容源会悄悄变——投稿入口、第三方导入、渲染方法复用到另一个页面,都是常见演化路径。
用了 DisableHtml 或 nohtml,是不是就安全了?
不是。官方原话是它只降低原生 HTML 输入的风险,本身不等于安全。链接和图片 URL 仍要过滤重写,javascript: 不会因为关了标签就消失。
中文里的加粗斜体渲染不出来,是库的 bug 吗?
多半不是 bug,是 CommonMark 规范对 CJK 两侧强调符号的处理。开 cjk-friendly-emphasis(或 .UseCjkEmphasis())即可,中文站点建议默认开。
每次请求都 new 一个 MarkdownPipeline 行不行?
能跑,但别这么干。构建管道有成本,官方明确说它线程安全、应该共享。做成 private static readonly 就行。
表格跟 GitHub 显示得不一样怎么办?
官方有严格 GFM 表格模式,用 PipeTableOptions.UseGfmRules 或 gfm-pipetables token 开启。默认偏宽松,对不齐时先试这个开关。
要把 Markdown 里的数据再结构化导出,思路跟 表格导出 Excel、HTML 解析成结构化数据 是同一套:先定字段,再定解析器,最后才是格式化。反向的 HTML 转 Markdown 不在 Markdig 里,社区常用 ReverseMarkdown,以对应仓库说明为准。

