跳到主内容

别再手写正则了:一套 C# Markdown 转 HTML 方案自测法,算清楚再动手

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-emphasisnohtml + 清洗清洗后可用

性能上泼点冷水: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,以对应仓库说明为准。

相关阅读

分享到:

本文链接:https://www.biyeyuanma.cn/post/265.html

服务热线

加我微信

加我微信