从 ReDoS 回归测试到源码:marked 如何保证内联代码跨度解析的线性时间复杂度
引言
test/specs/redos/link_code.md 是 marked 仓库中一个用于回归测试(regression test)的特殊测试用例:它以「N 个反引号开头但永不闭合」的链接文本(link text)作为输入,验证解析器不会因恶意输入而陷入正则表达式拒绝服务(ReDoS)攻击。该文档内容本身并非一篇技术教程,而是一条测试输入。因此,本文将围绕这条测试输入,结合 marked 的 src/rules.ts、test/run-spec-tests.js、test/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/redos与commonmark、gfm、new、original一并加载为测试组,并在 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 的流程是:
- 导入
block、inline、other三组规则(见 L1); - 遍历每个正则对象,取其
source与flags(见 L13-L24); - 调用
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.md | HTML 闭合标签 | 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.json 的 node --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.json 的 node test/recheck.ts > vuln.js。该命令对 src/rules.ts 导出的全部 inline、block、other 正则逐一做 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 安全工程的关键样本:
- 它验证了内联代码跨度在反引号不闭合时仍能线性时间完成解析,对应 src/rules.ts 的
inlineCode规则; - 它通过 test/run-spec-tests.js 在 CI 中持续回归;
- 它与
redos/目录下的 cubic_def.cjs、cubic_link_title.cjs、quadratic_link_empty_href.cjs 等脚本型用例一起,构成 marked 对正则复杂度的完整监控体系; - 配合 test/recheck.ts 的静态检测与 package.json 的
test:redos脚本,开发者在修改任何一条正则后都能立即确认是否引入了新的 ReDoS 风险。
对阅读者而言,这条用例提供了一个可复用的范式:为每一个「可能被攻击者构造」的语法分支,同时准备正确性断言(.md/.html)与复杂度上界用例(.cjs),并接入 CI 与静态扫描。这正是 marked 在保持解析速度的同时,能够长期抵御恶意 Markdown 输入的根本保障。 </|DSML|parameter> </|DSML|invoke> </|DSML|tool_calls>
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



