全部笔记

Markdown 不是安全格式:从作者权限到 HTML 输出的信任链

判断 Markdown 内容是否可信,并限制原始 HTML、链接与渲染插件的权限。

Markdown 文件里出现 <script>,编辑器可能只把它显示成文本,构建渲染器却可能保留原始 HTML。文件扩展名不会提供安全保证,真正的边界是内容来源、解析配置和最终输出方式。

本文区分两种场景:受信任作者维护的 Git 内容,与公开用户提交的内容。前者可以接受较高编辑权限,后者必须按不可信输入处理。不能因为当前只有一个作者,就把将来的评论接口接到同一渲染流水线。

同一段内容在不同出口具有不同语义

文章正文经过清洗后用于页面,摘要可能进入属性,标题可能进入 JSON,链接又可能进入 RSS。一次 HTML 清洗不能保证它在所有上下文中都安全。HTML 文本、属性、脚本数据和 URL 各有编码规则,应由对应框架与结构化 API 处理。不要把“已经清洗过的字符串”当成可以在任意位置直接拼接的万能安全值。

假设投稿标题包含引号,普通模板文本输出可以正确转义,但手写 <meta content="${title}"> 可能打断属性。正文里的代码示例含脚本标签,代码块应把它作为文本,而非执行。RSS 全文又需要合法 XML 与合适 HTML 语义。每个出口都应从明确的数据字段转换,不从已经拼好的页面 HTML 随意切片复制。

对可信作者也要区分编辑权限和执行权限。作者能够修改正文,不必自动获得构建环境令牌。多人投稿仓库若允许 MDX 导入任意模块,代码审查与运行环境隔离就属于必要条件。贡献者身份可信不代表其账号永远不被盗,也不代表每个复制的代码片段都经过检查。

把内容作为普通文本输出,框架通常会转义特殊字符。把渲染后的 HTML 注入页面,则要求输入已经通过合适的安全处理。一个允许 HTML 的 Markdown 解析器不会自动知道哪些属性、协议或标签可以执行脚本。

// 流程示意,具体插件配置应按项目版本核对。
const html = markdownToHtml(untrustedMarkdown);
const safeHtml = sanitizeWithAllowlist(html);

清洗器应使用成熟实现并采用允许列表,不能用正则删除 <script> 就结束。事件属性、SVG、危险 URL 协议和复杂嵌套都可能绕过这种处理。清洗发生在最终 HTML 阶段,并且清洗后不应再经过会新增任意 HTML 的不可信插件。

清洗顺序必须与插件能力一起审查

Markdown 解析后,高亮插件可能插入 span,链接插件可能添加属性,图表插件可能生成 SVG。若清洗太早,后续插件可以重新加入不受控 HTML;若清洗太晚且允许规则过窄,合法高亮或图表会被破坏。应先列出可信转换阶段,再在最终输出边界采用允许列表。清洗器本身也需要维护版本和测试,不能安装后永远不更新。

允许列表尽量按功能建立。技术文章需要段落、标题、列表、代码、引用和普通图片,但不一定需要 iframe、表单或任意样式。需要嵌入视频时,可以使用受控组件映射,而不是开放所有 iframe 属性。SVG 包含复杂能力,若来自不可信内容,应依据成熟清洗方案或转成受控静态图片,不用删除一个脚本标签就认为安全。

ID 与锚点也要检查。标题生成 ID 时,避免与页面现有重要元素发生冲突,并保持链接安全。重复标题需要稳定去重,目录不能因为用户输入含引号而生成危险属性。DOM 属性名称和 JavaScript 对象访问也可能受到特殊值影响,插件输出应使用结构化树而不是字符串拼接。安全审查关注完整转换链,而非只关注正文中有没有 script 字样。

链接协议需要独立规则

允许 HTTPS 和站内相对链接,通常比允许任意协议更符合博客需求。javascript:、某些 data URL 以及打开外部应用的协议,应根据场景限制。对新窗口外链使用合适的 rel 属性,但不要把它当成清洗的替代品。

图片也涉及隐私与资源风险。外部图片会向远程主机发送访问请求,可能成为追踪像素;巨大图片会增加加载成本。代理图片需要防 SSRF,不能让服务器请求任意用户提供的内部地址。每一种“方便自动处理”的能力都增加了一条信任边界。

浏览器直接加载外部图片会暴露访问请求给第三方;服务端代理可以减少这类暴露,却承担请求任意来源的风险。若允许内网地址、云元数据地址或重定向到这些地址,代理可能访问不该访问的资源。固定允许来源、限制协议和重新检查重定向目的地是常见起点,但网络与地址解析仍需采用成熟防护,不手写几个字符串判断代替完整策略。

代理还需要限制下载大小、响应类型、超时和转换尺寸。一个合法公网图片可能极大或解码成本异常,不能只根据文件扩展名决定安全。对缓存键限制变体数量,防止任意宽高组合造成昂贵转换。内容来源许可也独立存在,技术上能代理不代表拥有重新托管权利。

对个人静态站,如果图片只由作者维护,可以在构建时处理受控本地资产,避免公开任意 URL 代理。公开投稿平台则需要专门上传管道,验证并存储合适格式,再提供稳定地址。把不必要能力删除,往往比维护一套通用图片代理更安全简单。

MDX 具有比 Markdown 更高的权限

MDX 可以导入组件和表达 JavaScript,其能力适合受信任内容仓库,不适合直接处理公开投稿。即使最终站点是静态的,构建期间执行恶意代码也可能访问 CI 环境与凭据。

因此,公开投稿应进入受控数据管道,而不是把原文作为 MDX 文件运行。作者权限、依赖安装权限与 CI 权限要一起考虑:插件能在构建时执行代码,审查插件来源和版本同样属于内容安全。

作者需要提示框、代码标签或图片说明时,可以通过有限指令或结构化字段表达,由可信组件负责输出。编辑输入只选择组件类型和文本,不导入任意模块,也不传入任意事件处理函数。这样保持写作能力,同时让执行权限仍由项目代码拥有。公开投稿与管理员文章可以采用不同能力集,不必为了一个高级作者的需求开放全部投稿权限。

例如提示框支持信息、警告和注意三种类型,标题与正文经过正常文本或受控 Markdown 渲染;样式由组件决定,而不是允许用户输入 style。链接组件接受经过验证的地址与文本;嵌入组件接受供应商视频标识,再生成固定来源。允许的参数越清楚,测试就越容易覆盖,也更容易向编辑者解释拒绝原因。

拒绝不支持内容时给出可修复诊断,不无声删除整个段落。作者如果看到预览缺内容却不知道原因,可能尝试更复杂的 HTML 绕过。可以在私人编辑流程显示字段错误,但公开页面不暴露原始危险输入。发布前渲染差异检查有助于发现内容丢失,安全与可用性应共同验收。

对于已经受信任的仓库文章,审查重点可以转向原始 HTML、组件导入与外部资源的新增,而不是机械禁止所有高级能力。权限定级与来源明确以后,系统能够提供合适能力,不需要把“信任作者”解释成“信任任何字节”。复制来的示例、第三方插件和被盗账号仍然需要独立防护。

这套设计还有维护收益。受控组件升级一次可以改变全部提示框的可访问性和样式,而散落的手写 HTML 很难一致迁移。安全规则因此不只是限制写作,也能让文章结构更加稳定;前提是规则服务真实需求,不为了简化清洗而迫使作者把有意义的信息改成图片。

负向测试应该检查行为而不是外观

准备包含事件属性、危险协议、畸形标签和原始 HTML 的样本,在隔离测试环境渲染。检查最终 DOM 和浏览器是否执行,不能只看页面文字是否正常。再验证合法代码示例没有被误删,否则读者会得到不完整的技术内容。

对合法代码块应保留文本转义,不应因高亮工具插入内容而变成可执行 HTML。对生成标题锚点和目录的插件,也要检查 ID 与链接处理,避免重复或异常字符产生不安全输出。

最后增加 CSP 作为纵深防御,并保持依赖更新。对已进入 Git 的可信文章仍需人工审查,因为错误链接、误导性说明和泄漏配置不是清洗器能够发现的问题。安全渲染处理的是执行权限,可信写作处理的是内容质量,两者都不能互相代替。

一个更容易遗漏的出口是嵌入 HTML 的 JSON。标题作为普通文本安全,不代表把 JSON.stringify(record) 直接拼进 script 元素也安全:HTML 解析器先决定元素在哪里结束,随后 JavaScript 或 JSON 解析器才读取其中内容。即便 script 的类型标记为 JSON,也需要针对 HTML 中的承载上下文处理结束序列,而不是只证明对象可以序列化。

测试使用包含合成 </script> 文本的标题,检查最终 DOM 是否仍只有预期的数据元素,再由消费者解析并比较原标题。既要防止意外创建其他节点,也要保留作者正在展示的字面文本。使用框架支持的安全序列化机制,不在文章里发明一套字符替换;同一 JSON 若独立作为 application/json 响应发送,承载边界又不同。

数据读出以后还会进入第二个上下文。搜索组件解析 JSON 后,若把标题送入 innerHTML,就重新把普通数据赋予 HTML 能力。沿一条标题记录追踪“序列化、HTML 承载、解析、DOM 文本输出”四步,每步检查实际 API。前一步安全不是后一步可以任意注入的许可,这个测试比只扫描 Markdown 中的危险标签更能发现跨出口漏洞。

用结构化提示框代替任意 HTML 的具体契约

假设投稿者需要一个“迁移注意事项”框。输入可以只包含类型、标题和受控 Markdown 正文,类型限定为 note、warning 与 caution。组件决定标签、类名和可访问结构,不允许投稿者提供事件属性、任意样式或组件导入。这样投稿者获得表达能力,项目仍然拥有执行能力。字段缺失时返回明确诊断,而不是将原始内容作为 HTML 兜底输出。

正文是否允许嵌套提示框也要规定。无限嵌套会增加解析与布局成本,合法输入同样可能造成资源压力。限制最大深度、内容长度和插件能力,是可用性与安全的一部分。这里的上限来自编辑需求与测试条件,不是复制一个任意数字。遇到超限应告诉作者如何拆分内容,不无声截断代码或关键警告。

对链接组件,输入是地址与显示文本,输出前独立验证协议和目标形式。对视频组件,输入供应商标识而非完整 iframe,可信组件生成固定来源。若供应商增加新参数,先审查其作用再扩展 Schema。不要通过一个“其他属性”字典把所有限制重新开放,否则结构化组件只是给任意 HTML 换了名字。

使用正向样本证明正常功能:警告标题可被阅读器识别,代码保持可复制,链接具有预期地址。使用负向样本证明边界:未知组件类型、事件字段、超限嵌套和非法地址被拒绝。组件与清洗器共同工作的最终 DOM 也要检查,不能只证明输入对象符合类型。编译类型控制开发者输入,运行时校验控制真实投稿,两者作用范围不同。

从解析树保留代码与正文的语义区别

一篇安全文章本身可能需要展示 <script> 或危险协议。它们出现在代码节点中时应该保留为文本,出现在真实链接或原始 HTML 节点中时则要按规则拒绝。正则替换难以理解这种上下文,可能既漏掉危险节点,也删掉读者需要的示例。先解析成结构化树,再在明确节点类型上处理,能够保留写作语义。

高亮插件输出的类名与元素也应限制在实际需要的集合。允许任意 style 属性会扩大呈现能力,允许任意 ID 可能影响页面锚点与脚本选择。可以给正文生成的 ID 使用专门前缀,降低与导航或应用控件冲突的概率,但仍需检查重复与引用一致性。前缀不是执行安全的替代品,只解决一部分命名空间问题。

经过清洗的 HTML 不应再送进一个会重新解析并添加不可信内容的插件。若必须有后处理,列出其能力并重新在最终边界验证。测试可以为每个阶段保存合成节点清单,定位危险属性由哪一阶段加入,而不是只看到最后字符串不对。这样升级插件时能精确判断变化来自解析、转换还是清洗规则。

资源安全包含构建机的消耗

不可信 Markdown 可以不执行脚本,却通过超长单行、复杂嵌套或大型图表让渲染占用大量时间。公开投稿入口要限制输入大小,构建或渲染任务要有资源预算和超时。图表工具若调用外部程序,更需要隔离文件访问与网络能力。不能因为结果最终是一张图片就假设生成过程没有执行风险。

受信任仓库中也要关注依赖权限。构建插件运行时可能读取文件和环境变量,内容作者若可修改插件配置或 package 脚本,其权限已经超出正文编辑。把投稿资料与构建代码放在不同审查边界,公共贡献测试不注入生产秘密。预览需求可以通过隔离构建满足,不用为了方便让每次外部投稿都在生产部署凭据旁执行。

结果缓存需要包含解析器、插件和清洗规则版本。修复清洗漏洞后继续复用旧 HTML,会让源内容“没有变化”的对象保留漏洞。内容指纹能识别输入,转换版本能识别处理语义,两者共同决定输出身份。缓存失效与重新构建全部受影响记录,应属于安全更新的实施清单。

预览与发布不能采用矛盾的能力集

如果预览允许 HTML、正式发布删除 HTML,作者可能在发布后才发现关键段落消失。可以在私人编辑界面清楚展示拒绝诊断,使用与公开渲染相同的内容规则。开发热更新脚本等环境差异单独处理,不通过放开正文输入解决。预览权限控制访问者,渲染规则控制内容能力,它们不是同一个开关。

发布前比较语义节点而非仅截图。重要代码块、链接和提示框数量是否保持,替代文本是否存在,合法内容是否被意外删除。视觉截图可以发现布局问题,但无法证明链接协议与属性安全。最终验收同时检查结构、行为和内容完整性,让作者不必通过绕过清洗器来恢复正常表达。

对其他出口保持同一来源边界

搜索索引应提取普通文本,而不是把已渲染 HTML直接序列化后再由结果组件注入。RSS 全文应采用自己的静态输出约束,社交摘要使用文本字段。转换器可以共享来源状态与已批准内容,但每个出口仍完成对应上下文编码。这样一个文章获得正文 HTML 能力,不会自动在脚本数据、属性或 XML 中获得同样权限。

漏洞修复之后还要检查已发布副本

若发现旧文章能够执行注入,修复渲染器并重新构建所有受影响页面,检查 CDN 与 RSS 是否仍提供旧输出。只修复新投稿入口不会改变已生成 HTML。必要时清理缓存并评估访问日志,但已经被阅读器保存的副本不能保证删除,因此对暴露秘密还必须撤销相应凭据。

责任分界可以很明确:内容输入负责来源与作者权限,转换器负责合法语法,清洗器负责允许的执行能力,模板负责上下文编码,部署负责额外策略与完整更新。任何一层都不能宣称替代其他层。将这些职责写入测试和发布检查,Markdown 才从一个看似纯文本的文件,变成一条可审计的内容处理链。

参考:OWASP XSS 防御、HTML:script 内容限制、MDX 文档。