从 ReDoS 回归测试到源码:marked 如何保证内联代码跨度解析的线性时间复杂度

从 ReDoS 回归测试到源码:marked 如何保证内联代码跨度解析的线性时间复杂度

【免费下载链接】marked A markdown parser and compiler. Built for speed. 【免费下载链接】marked 项目地址: https://gitcode.com/gh_mirrors/ma/marked

引言

test/specs/redos/link_code.md 是 marked 仓库中一个用于回归测试(regression test)的特殊测试用例:它以「N 个反引号开头但永不闭合」的链接文本(link text)作为输入,验证解析器不会因恶意输入而陷入正则表达式拒绝服务(ReDoS)攻击。该文档内容本身并非一篇技术教程,而是一条测试输入。因此,本文将围绕这条测试输入,结合 marked 的 src/rules.tstest/run-spec-tests.jstest/recheck.ts 等源码与配置,完整说明该用例的由来、含义、验证方式,以及它背后所保障的 marked 安全性与线性时间解析能力。

1. 该文档在 marked 中扮演的角色:ReDoS 回归测试用例

1.1 测试用例的物理构成

在 marked 仓库中,test/specs/redos/link_code.md 与其同名 .html 文件共同构成一条**「规范测试(spec test)」**。.md 文件是输入,.html 文件是期望输出。其实际内容(markdown 输入)为:

INDEX(string, pattern[, start])` : searches for the first occurrence of pattern in string, starting from start: `INDEX("123123", "23", 3)` == `5`
INSERT(new, old[, start][, length][, pad]) : inserts the new string into the old string after the specified position (default is 0), new string is truncated or padded (default is " ") to the specified length, if start is beyond the end of old old will be padded
LASTPOS(pattern, string[, start]) : searches backwards for the last occurrence of pattern in string, starting from start: `LASTPOS("123123", "23", 4)` == `2`
LINES(file) : returns the number of lines typed ahead at the interactive stream: `push("a line"); push("second line"); lines(STDIN); /* == 2 */`
MAX(number, number[, number,...]) : obvious
MIN(number, number[, number,...]) : obvious
OPEN(filehandle, filename[, "APPEND"|"READ"|"WRITE"]) : opens file, returns boolean for success: `OPEN("MyCon", "CON:160/50/320/100/MyCon/CDS")` == `1`
OVERLAY(new, old[, start][, length][, pad]) : overlays new string onto old one at start for length chars padding with pad if necessary: `OVERLAY("4", "123", 5, 5)` == `"123-4----"`
POS(pattern, string[, start])` : same as index

注意:原始输入中只有 9 行且每行均包含若干成对的反引号(`)用于标记内联代码(inline code)。但其中首个 INDEX 行与最后一行 POS 行各有一个反引号是不闭合的(例如 INDEX(string, pattern[, start) 与 `POS(pattern, string[, start])`` 的尾反引号配对问题),这正是 ReDoS 测试的「陷阱输入」——它迫使解析器在反引号不配对时仍然以线性时间完成解析,而不是在回溯中耗尽 CPU。

不过需要说明:.html 期望输出显示,解析器未把不配对的反引号当作代码跨度,而是将其作为普通文本输出(<p> 包裹、特殊字符转义),且处理过程在常数/线性时间内完成。这验证了 marked 在面对「大量反引号 + 括号 + 链接语法」的组合输入时不会出现平方或指数级回溯

1.2 为什么放在 redos/ 目录

test/specs/redos/ 是 marked 专门存放 ReDoS(Regular expression Denial of Service,正则表达式拒绝服务) 回归测试的目录。相关证据如下:

  • docs/CONTRIBUTING.md 中明确列出:

    /test/specs/redos —— Tests for ReDoS vulnerabilities

  • test/run-spec-tests.js./specs/redoscommonmarkgfmneworiginal 一并加载为测试组,并在 L47-L51 中以 defaultMarkedOptions: { silent: false } 运行这些用例——即不允许静默吞掉解析错误,任何异常都会被当作测试失败暴露出来。

因此,link_code.md 的目标是:即使输入中的反引号不闭合、并伴随大量 [ ] ( ) 链接相关字符,marked 也必须在线性时间内输出正确结果,且不触发 ReDoS

2. 从源码看 marked 如何让这条输入在线性时间内完成解析

2.1 内联代码跨度(code span)规则

marked 在 src/rules.ts 中定义内联代码(code span)的匹配规则:

const inlineCode = /^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/;

这条规则的含义是:

  • ^(+ ):以 1 个或更多反引号开头,捕获为一组\1`;
  • ([^]|[^][\s\S]*?[^]):中间内容不能以反引号开头或结尾(非贪婪*?`);
  • \1(?!)`:结尾必须出现与开头完全等长的反引号序列,且其后不能再跟反引号。

对于 link_code.md 中的输入(如 INDEX(string, pattern[, start) 后接 ` 再到 : searches... 再到 ` 闭合),该规则能够正确匹配;而对于不闭合的反引号,规则会快速失败并回退为普通文本,不会产生灾难性回溯。这一正则正是 ReDoS 测试重点保护的代码路径。

2.2 内联代码在 inline 规则集中的注册

src/rules.ts 中,inlineCode 被注册为 inline 规则集的 code 规则:

inline: {
  code: inlineCode,
  // ...
},

inline 规则集随后被导入 test/recheck.ts

import { block, inline, other } from '../src/rules.ts';

这意味着 link_code.md 用例所验证的 inlineCode 正则,会直接进入 ReDoS 静态检测器的检查范围。

2.3 静态 ReDoS 检测:test/recheck.ts

marked 使用 recheck 工具对所有导出正则做静态安全分析。test/recheck.ts 的流程是:

  1. 导入 blockinlineother 三组规则(见 L1);
  2. 遍历每个正则对象,取其 sourceflags(见 L13-L24);
  3. 调用 check(source, flags)(见 L27),根据返回状态打印 // safe 或构造攻击样本输出 // marked(...)(见 L30-L40)。

该脚本通过 npm script 触发:

  • package.json"test:redos": "node test/recheck.ts > vuln.js"

即运行后生成 vuln.js,其中列出所有被判定为 vulnerable 的正则及复现输入。任何导致 inlineCode 或相关规则出现平方/指数复杂度的改动,都会在这里被暴露。

2.4 运行时测试:run-spec-tests.js

除了静态检测,test/run-spec-tests.js 会把 redos 目录下的所有 .md/.html 对作为真实解析任务运行:

runTests({
  tests: redosTests,
  parse,
  defaultMarkedOptions: { silent: false },
});

link_code.md 作为其中一条,在 CI 中被持续执行,任何导致解析超时或输出与 .html 期望不符的回归都会被立即发现。

3. 同类用例:ReDoS 测试族的整体设计

link_code.md 并非孤立用例,test/specs/redos/ 下还包含多类针对不同风险点的测试,它们共同构成 marked 的 ReDoS 防护网:

用例输入特征风险点
link_code.md反引号不闭合 + 括号/链接字符混排内联代码与链接语法的组合回溯
backticks_in_link_label.md超长反引号序列作为链接文本链接标签内的代码跨度解析
backticks_in_link_no_close.md链接内反引号永不闭合链接解析失败路径
link_redos.md链接相关特殊字符组合链接正则的灾难性回溯
reflink_redos.md引用式链接(reference link)引用链接匹配
redos_nolink.md无链接却含链接诱饵字符非链接路径的误匹配
redos_html_closing.mdHTML 闭合标签HTML 块结束匹配

此外还有大量 .cjs 脚本型用例(同样被 getTests 加载),用于直接构造超长攻击输入并断言固定期望输出,例如:

  • cubic_def.cjs[x]: + 1500 空格 + x + 1500 空格 + x,验证定义(def)路径O(n) 级别完成;
  • cubic_link_title.cjs'ab,验证链接标题路径不会出现立方级耗时;
  • quadratic_link_empty_href.cjs'[](' + ' '.repeat(50000),验证空 href + 超长空格的链接路径保持线性。

这些用例与 link_code.md 相互补充:.md/.html 对验证「正确性」,.cjs 脚本验证「性能上界」。

4. 实战:如何在本地复现并验证这条 ReDoS 用例

仓库为只读,以下操作仅用于本地查看与验证,不修改任何文件。

4.1 查看测试输入与期望输出

cat test/specs/redos/link_code.md
cat test/specs/redos/link_code.html

期望输出显示:未配对的反引号被当作普通文本处理,字符 &" 等被正确转义,整体被包裹在 <p> 段落中——说明解析器在反引号不闭合时快速失败,而不是在多个候选分支间反复回溯。

4.2 运行整套规范测试

npm run test:specs

该命令对应 package.jsonnode --test --test-reporter=spec test/run-spec-tests.js,会依次运行 CommonMark、GFM、new、original 与 redos 全部用例,link_code 用例通过即代表该输入路径未被破坏。

如需只看 redos 组,可临时以 --test-name-pattern 过滤(以实际环境为准),或在 run-spec-tests.js 中仅保留 redosTests 分组运行。

4.3 运行静态 ReDoS 检测

npm run test:redos

即执行 package.jsonnode test/recheck.ts > vuln.js。该命令对 src/rules.ts 导出的全部 inlineblockother 正则逐一做 recheck 静态分析,将结果写入 vuln.js

  • 若打印 // safe,则该正则被判定为无 ReDoS 风险;
  • 若打印 // marked(...),则会附带攻击样本与所需选项(pedantic/gfm),便于复现。

inlineCode(即 inline.code)正是被检查对象之一,因此 link_code.md 所守护的代码路径在静态层面也受到持续监控。

4.4 手动验证(不依赖测试框架)

可以直接用 Node 调用 marked 的内联解析,观察耗时与输出:

node -e "
const { Marked } = require('./lib/marked.cjs');
const md = require('fs').readFileSync('test/specs/redos/link_code.md', 'utf8');
const t = Date.now();
const out = new Marked().parse(md);
console.log('elapsed(ms):', Date.now() - t);
console.log(out === require('fs').readFileSync('test/specs/redos/link_code.html', 'utf8') ? 'PASS' : 'MISMATCH');
"

该脚本以毫秒为单位记录解析耗时,并断言输出与期望 .html 完全一致;若耗时随输入长度呈现线性增长(而非平方/指数),即证明 ReDoS 防护生效。

5. 工程意义:为什么 marked 需要这一整套 ReDoS 测试

5.1 风险来源:Markdown 解析天然「多分支回溯」

Markdown 语法高度依赖正则匹配,而「链接文本内可嵌入代码跨度、代码跨度内又涉及反引号计数」这类嵌套/歧义结构,正是回溯型 ReDoS 的高发区。link_code.md 的输入刻意制造了「不配对的反引号 + 括号 + 链接伪语法」的组合,专门用来探测 inlineCode 与链接规则的交互是否存在指数级回退。

5.2 双层防线:静态检测 + 运行时回归

  • 静态防线test/recheck.ts + recheck 库在开发期扫描全部规则,把「易受攻击的正则」在合入前拦截;
  • 运行时防线test/run-spec-tests.js 在 CI 中持续执行 redos 目录下全部用例,确保既有防护不被后续改动破坏。

两者结合,使得「新增语法特性」与「保持线性时间」之间形成可验证的平衡。从 docs/USING_ADVANCED.md 可以看到,marked 官方还建议在不可信输入场景下,将解析放入 Web Worker 并在超时后终止进程,作为 ReDoS 的最后一道运行时兜底。

6. 总结

test/specs/redos/link_code.md 虽只是一条测试输入,却是理解 marked 安全工程的关键样本:

对阅读者而言,这条用例提供了一个可复用的范式:为每一个「可能被攻击者构造」的语法分支,同时准备正确性断言(.md/.html)与复杂度上界用例(.cjs),并接入 CI 与静态扫描。这正是 marked 在保持解析速度的同时,能够长期抵御恶意 Markdown 输入的根本保障。 </|DSML|parameter> </|DSML|invoke> </|DSML|tool_calls>

【免费下载链接】marked A markdown parser and compiler. Built for speed. 【免费下载链接】marked 项目地址: https://gitcode.com/gh_mirrors/ma/marked

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值