Markdown 渲染的安全边界:预览、插件与公开页面用同一套规则

沿 Markdown 到 HTML 的转换过程识别不可信输入,给出保守的 rehype 渲染示例,说明插件顺序、URL 策略、缓存版本和预览一致性。

·6 min
Markdown 源文档经过解析与清洗,危险片段被移除,安全内容进入预览页面。

编辑器里看似普通的 Markdown,经过插件后可能变成 HTML、链接、图片或嵌入内容。浏览器只处理最终结果,输入叫 Markdown 并不会降低这些内容的权限。

内容平台需要沿转换链路确定安全边界:哪些语法允许作者表达,哪些节点由受信任代码生成,公开页面最终接受什么。预览和公开渲染若使用不同规则,作者看到的效果与读者实际加载的内容也会不同。

先决定原始 HTML 的范围

个人博客可能只需要段落、标题、列表、表格、代码和普通链接。这种需求下,不支持任意 HTML 往往更容易维护。

如果允许 <details> 或自定义布局,渲染器应先按 HTML 语义解析,再按允许列表清洗。删除 <script> 的正则表达式不足以处理事件属性、URL 协议、SVG 和解析器差异。

账号可能被盗,历史内容可能来自导入程序,复制的片段也可能带有作者没注意的属性。作者身份无法替代清洗。渲染策略应覆盖这些内容来源。

在最后一次不可信转换后清洗

一个保守流程可以写成:

Markdown
  -> Markdown AST
  -> 受控语法扩展
  -> HTML AST
  -> 允许列表清洗
  -> HTML 序列化

HTML AST 即表示 HTML 节点与属性的语法树。只有需要支持原始 HTML 时,才在清洗之前加入相应解析步骤。启用原始 HTML 却绕过树级清洗,会改变这条边界。

这段 ESM 代码使用 unifiedremark-parseremark-rehyperehype-sanitizerehype-stringify,展示一条禁用原始 HTML 的基础路径。它没有处理 GFM 表格、数学公式或代码高亮:

import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import rehypeSanitize from 'rehype-sanitize';
import rehypeStringify from 'rehype-stringify';

const processor = unified()
  .use(remarkParse)
  .use(remarkRehype)
  .use(rehypeSanitize)
  .use(rehypeStringify);

export async function renderMarkdown(source) {
  const file = await processor.process(source);
  return String(file);
}

应用需要锁定相互兼容的依赖版本,并用自己的允许语法集合验收。这个示例没有放宽清洗 schema,也没有在输出后拼接作者提供的 HTML。

remark-rehype安全说明指出,自定义处理器和节点属性也能影响结果。rehype-sanitize 则要求放在最后一个不可信转换之后。清洗完成后再执行一个可以插入任意节点的插件,会引入新的信任前提。

代码高亮、公式和图表插件可能需要特定类名或标签。处理方式应该是审查插件输出,再按需要扩展允许列表,或者明确将某个后续步骤视为受信任代码。不要为了保留视觉效果就允许任意 style、事件属性或任意 SVG。

分开制定链接和图片政策

普通链接、图片地址和视频嵌入不一定使用同一套协议限制。业务可以允许普通链接的 HTTPS 和站内相对路径,却只允许图片来自指定媒体域名。是否支持邮箱链接、数据 URL 或第三方 iframe,应单独决定。

协议检查应针对解析与规范化后的 URL 进行,不能只判断原始文本是否以某个字符串开头。控制字符、大小写和编码形式可能影响解释,路径与协议判断应交给可靠的解析器和清洗库处理。

图片地址即使不执行脚本,也可能向第三方暴露访问记录。如果页面直接加载外链图片,需要考虑用户隐私、来源策略和内容稳定性;如果服务端替用户抓图,还会产生 SSRF 风险,需要另一套出站网络与目标地址限制。XSS 清洗不能解决服务端抓取内网的问题。

自动生成的目录 ID、脚注锚点和作者属性也需要规则。可控的 idname 可能干扰 DOM 对象访问。采用库的默认防护时,应保留它为这些值添加的前缀和限制。

让预览与公开结果共享策略版本

编辑器、服务端和搜索摘要各用一套渲染器时,策略很快会漂移。只修复其中一个入口,其他入口仍可能输出未受控结果。

更清楚的方式是共享同一个渲染模块与策略配置,或让预览调用同一套受控渲染服务。浏览器和服务端必须分别运行时,至少共享样本与预期结果,并将依赖和策略版本纳入发布检查。

存储上可以保留 Markdown 原文,将 HTML 当作派生内容。缓存键应包含内容版本和渲染策略版本。清洗策略升级后,要处理历史 HTML 缓存,而不仅仅是新文章;重新生成时也应有范围、失败记录和回退方案。

“回退”不能重新启用已确认不安全的策略。遇到兼容问题,可以暂时降级到更保守的文本呈现,同时保留原文等待重新渲染。不要为了恢复一个装饰效果恢复危险属性。

用实际显示结果验收

安全验收既要检查危险内容被阻断,也要确认普通文档仍然可读。下面这些字符串只作为离线输入样本,不应作为未转义 HTML 插入测试页面:

[link](javascript:alert(1))
<img src="x" onerror="alert(1)">
<script>alert(1)</script>

这几个样本无法证明清洗器安全。还需要覆盖编码变体、异常嵌套、协议边界,以及当前插件允许的节点。更重要的是补充正常样本:代码块中的脚本应显示为代码,普通链接应可用,禁用原始 HTML 时的行为应符合文档约定。

应通过预览、公开页及其他实际输出入口检查最终结果。HTML 字符串相同是一个线索,但浏览器解析后的 DOM 与实际加载行为也值得检查。测试过程应放在隔离环境,不能拿生产用户页面试验危险输入。

即使完成清洗,后续调用方也不能把输出当成适用于任何上下文的万能安全字符串。HTML 正文、属性、JavaScript 字符串和 URL 有不同的转义要求。OWASP 的 XSS 防护说明也提醒,清洗后再修改内容可能破坏已有防护。

Markdown 的维护对象是整条转换路径。新增插件、开放嵌入语法或改变缓存策略后,都要重新检查信任边界。语法政策和回归样本应随代码一起维护。