两条 O(n²) 路径的潜伏与闭合:Tiptap Markdown 属性解析 ReDoS 全链路解剖 GHSA-j95f-988m-3j2f
@tiptap/core >=3.7.0,<3.30.5 的 Markdown 属性解析里存在两条二次时间复杂度路径:Pandoc 风格的块属性解析器(attributeUtils.ts)在键值匹配和清理两个阶段各执行一次同型的未锚定贪婪搜索;行内 shortcode 解析器(createInlineMarkdownSpec.ts,以及 Mention 扩展自带的内联实现)对无等号的长词串从每个位
TL;DR
@tiptap/core >=3.7.0,<3.30.5 的 Markdown 属性解析里存在两条二次时间复杂度路径:Pandoc 风格的块属性解析器(attributeUtils.ts)在键值匹配和清理两个阶段各执行一次同型的未锚定贪婪搜索;行内 shortcode 解析器(createInlineMarkdownSpec.ts,以及 Mention 扩展自带的内联实现)对无等号的长词串从每个位置反复回溯。约 20–33 KB 的输入(实测 20,508 B 与 32,772 B 两档)就能换取数百毫秒至秒级的同步 CPU 占用,输入翻倍、耗时约四倍。本文基于本地克隆的上游仓库,逐行还原两条路径的回溯机制与 __QUOTED_N__ 占位符数据流,用隔离 Docker 拓扑复现完整链路——单次解析、事件循环排队、worker 占用、浏览器端重开持久化文档的重复冻结(服务端持久化的仅是文档参数而非 Markdown 原文,口径见 5.2 节)——并解剖 3.30.5 修复中 tokenizer 与锚定正则两种手法。结论限定在库级与实验拓扑:这是一个条件闭合的可用性漏洞,公开材料与本地验证中均未发现 RCE、越权或数据泄露的证据。
数据与引用口径。本文代码引用格式为
tiptap@<tag|commit> → 文件:行号;tag 对应完整 commit:v3.7.0=dab80e94、v3.29.2=5158212970、v3.30.4=55f59e4907、v3.30.5=b0c188b1bc(完整哈希见文末参考)。除官方公告数据外,全部实验数据产生于隔离 Docker 拓扑:node:24.20.0-bookworm-slim(V8 13.6.233.17-node.53)、Docker Linux/arm64,基线应用容器 1 CPU/256 MiB/100 PIDs,并发对照(D5)为 2 CPU,浏览器 runner(Chromium 153)为 2 CPU/1 GiB。计时口径随实验注明(预热次数、样本数、取值方式),绝对耗时不可外推到其他硬件或引擎版本。随文附件(仓库repro/目录)提供双版本 Docker 拓扑与文中全部实验脚本,按repro/README.md的命令即可逐项复现 5.3 节六条目与 D1–D7 的数据(正文 5.4 节的最小复现流程同样依赖这套附件,不是脱离仓库的自包含方案);所有解析与探测都在本地 Docker 拓扑内完成:对宿主发布的端口仅绑定127.0.0.1,容器之间的探测流量走隔离的 Docker bridge 网络,不触达任何第三方主机。文中图 1–6 由 D2 源码渲染为 PNG 插入,源文件与重渲脚本(render.sh)在仓库assets/d2/目录下。实验环境锁定为可重建:基础镜像 digest、按版本提交的依赖 lockfile(repro/locks/,npm ci安装)与构建/运行环境版本均已记录在案。正文全部实验数字取自 2026-09-23 定稿最终回归轮,原始逐行输出、环境清单与文件 SHA-256 归档于repro/results/2026-09-23-final-regression/,按文件名即可逐项对应;更早研究轮次的记录(原始输出未归档)见同级复现文档,与正文数字属于不同 run,不混用。文中推断与待验证项均显式标注。
1. 漏洞定位:公告说了什么、没说什么
GHSA-j95f-988m-3j2f 的受影响对象是 npm 包 @tiptap/core,范围 >=3.7.0, <3.30.5,3.30.5 修复。GitHub Reviewed 记录给出 CVSS v4.0 8.7(High),向量 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N;维护者的仓库公告页则标 Moderate。核对两个官方 API 记录后可以确认:仓库级记录只有 severity: medium 定性字段,CVSS v3/v4 向量与分数都是空——这不是两条可逐字段比较的 CVSS 评分,差异不能归因于环境权重或评分版本。需要量化技术严重性时,引用带完整向量的 Reviewed 记录(FIRST 官方 FAQ 恰好用相同向量示例确认得分为 8.7);维护者为什么选 Moderate,不必臆测。
公告明确标注 No known CVE,后果很具体:无法按 CVE 编号查询 NVD/CPE,也无法按 CVE 维度核对 CISA KEV——正确的表述是"KEV 不适用、无法按 CVE 交叉核验",而不是"未列入 KEV"。更实际的代价在供应链侧:仅以 CVE 号为主键的漏洞聚合、SCA 告警或工单关联规则不会命中这条公告;GHSA-only 漏洞在企业依赖治理中的覆盖率,值得当成一个自查项(见第 7 章)。
公告同时列出两条 CWE,分工要说清楚:CWE-1333(Inefficient Regular Expression Complexity,低效正则复杂度)与 CWE-400(Uncontrolled Resource Consumption,不受控资源消耗)。按 MITRE CWE 4.20 的映射状态,CWE-1333 是 ALLOWED,也是本案的精确根因;CWE-400 是 DISCOURAGED 的上位资源消耗分类,只按公告元数据保留,不应与根因并列解释。
定性上,这是多项式(二次)复杂度 ReDoS,不是教科书式的指数回溯。USENIX Security 的 ReDoS 研究(ReDoSHunter、RegexScalpel)把 ReDoS 的计算条件定义为正则匹配的超线性最坏时间,明确覆盖多项式与指数两类;MITRE 对 CWE-1333 的描述是"最坏复杂度低效且可能为指数级",并不排除多项式。因此本文使用"二次(多项式)ReDoS",不用暗示指数增长的"灾难性回溯"来下复杂度结论——为什么这里是二次而不是指数,第 3 章给出机制证明。
能力边界同样来自公告与代码:CVSS 向量的机密性、完整性维度全部为 N(VC:N/VI:N/VA:H),公开材料中没有任何 RCE、越权或数据泄露的证据。本地版本基线与公告互证:仓库内 tag v3.7.0(dab80e94)与 v3.30.5(b0c188b1bc)都存在,引入提交 35645d94(2025-10-14,"Markdown Support (#6821)")与修复提交 d0d499be(2026-08-26)可以在本地 git 历史中完整定位。从 3.7.0 发布到修复发布,这段代码潜伏了约十个月。
2. 攻击面与信任边界:Markdown 如何到达脆弱代码
Tiptap 是编辑器库,不是服务。它不监听端口、不做认证,攻击面是否存在,第一道闸门在宿主应用的配置。官方文档显示编辑器内容的 contentType 默认为 json——只有宿主显式使用 contentType: 'markdown'、editor.markdown.parse()、MarkdownManager.parse(),或直接调用 tokenizer/属性解析 API 时,文档字符串才会进入 Markdown 管线。仅安装 @tiptap/core 不产生攻击面;只接收经过验证的 ProseMirror JSON、从不走 Markdown 解析路径的编辑器,也不会经文档内容触发本漏洞。
2.1 从字符串到属性串的调度链
启用 Markdown 后,上层入口汇聚到同一条管线(tiptap@v3.29.2,@tiptap/markdown 依赖 marked@^17.0.1,本拓扑构建实际解析并被 repro/locks/ lockfile 固定在 17.0.6,无其他预解析器;下文 marked 源码行号按 v17.0.1 tag 引用,17.0.1→17.0.6 的 Lexer.ts 仅有 tokenizer 反向引用与 del 签名等少量改动、107 行后行号整体 +2,调度结构未变):

图 1|从不可信 Markdown 字符串到脆弱解析器的调度链
两个事实决定了这条链上没有任何缓冲。第一,Manager 把完整输入字符串直接交给 marked lexer;marked 的调度(marked@17.0.1 → src/Lexer.ts:82-92,block 先行、inline 随后;:99-115、:312-329 在每个剩余片段上先尝试自定义 tokenizer)只是语法路由,startBlock/startInline(:213-242、:401-431)只用于防止普通段落跨过潜在扩展起点,不是安全预过滤。第二,从 Editor 命令包装层到 Manager 再到 tokenizer,整条链没有长度、超时、中止或复杂度闸门;schema 转换、扩展 handler 是否接受 token、atom 的 requiredAttributes 校验,全部发生在脆弱属性正则之后,对 CPU 消耗没有阻断作用。
2.2 提取层的边界:什么输入能到达 parseAttributes
块级 helper 用行首锚定正则从 :::name {attrs} 结构中提取属性串。两个 helper 的关键差异在调用顺序:
// tiptap@v3.29.2 → packages/core/src/utilities/markdown/createAtomBlockMarkdownSpec.ts:105-123(节选,行号注释为本文所加)
tokenize(src, _tokens, _lexer) {
// Use non-global regex to match from the start of the string
// Include optional newline to ensure we consume the entire line
const regex = new RegExp(`^:::${blockName}(?:\\s+\\{([^}]*)\\})?\\s*:::(?:\\n|$)`) // :108
const match = src.match(regex)
if (!match) {
return undefined
}
// Parse attributes if present
const attrString = match[1] || '' // :116 捕获组原样传入
const attributes = parseAttributes(attrString) // :117 ← 立即解析
// Validate required attributes
const missingRequired = requiredAttributes.find(required => !(required in attributes)) // :120 校验发生在解析之后
if (missingRequired) {
return undefined
}// tiptap@v3.29.2 → packages/core/src/utilities/markdown/createBlockMarkdownSpec.ts:124-139(节选,行号注释为本文所加)
tokenize(src, _tokens, lexer) {
// Match the opening tag with optional attributes
const openingRegex = new RegExp(`^:::${blockName}(?:\\s+\\{([^}]*)\\})?\\s*\\n`) // :126
const openingMatch = src.match(openingRegex)
if (!openingMatch) {
return undefined
}
const [openingTag, attrString = ''] = openingMatch // :133
const attributes = parseAttributes(attrString) // :134 ← 在此解析
// Find the matching closing tag by tracking nesting level
let level = 1 // :137 嵌套/闭合扫描从这里才开始
const position = openingTag.length
let matchedContent = ''container helper 在扫描嵌套内容和闭合 ::: 之前就解析了开始行属性。哪怕后文没有闭合 :::、tokenizer 最终返回 undefined,脆弱解析器也已经被调用过——语法不完整的 container 块同样构成入口,官方 PoC 那种闭合 atom 形态 :::probe {...} ::: 并不是唯一入口。提取正则 [^}]* 保留空白、允许跨行,在第一个 } 处终止,不理解引号或嵌套花括号:
输入边界 | 到达 |
无 | 传入空串或纯空白,函数内 |
缺失 | 开始正则整体失败,不调用 |
| 内层 |
引号值中出现 | 提取层不理解引号,仍在此 |
属性或定界跨行 |
|
提取层自身是 O(n):完整匹配以 ^ 限定在单一起点,属性捕获单向扫描,静态审计未发现独立的超线性路径,二次放大全部来自下游的 parseAttributes。缩进容错要按版本区分:v3.29.2 与 v3.30.5 的两个 helper 都不接受前导空格;tiptap@5c7200d11(2026-09-16)才把 atom helper 扩展为 0–3 个前导空格,container helper 在当前 HEAD 仍不接受缩进——不能把后续版本的语法倒推成漏洞版本的可达入口。
2.3 三条入口与现实调用面
综合调度链与公开导出,通向脆弱代码的入口有三条:
- 路径 A,块属性:
createAtomBlockMarkdownSpec(tiptap@v3.29.2 → createAtomBlockMarkdownSpec.ts:117)与createBlockMarkdownSpec(createBlockMarkdownSpec.ts:134)调用 Pandoc 风格parseAttributes,这是 Manager 管线内默认 Pandoc 解析器的全部两个内部调用点。 - 路径 B,行内 shortcode:
createInlineMarkdownSpec.ts:250调用独立的 shortcode 解析器;官方 Mention 扩展默认以[@ …]形式使用它,并注入了自己的同型实现(第 4 章)。 - 路径 C,直接调用公开导出:
packages/core/src/utilities/markdown/index.ts:8-13以export *全量导出 attributeUtils,parseAttributes是公开 API,应用可绕过 Manager 直接把不可信字符串送进去。
现实调用面由官方扩展构成:对 v3.29.2 全仓 grep,extension-youtube、extension-audio、extension-twitch、extension-details(3 个源文件)、extension-mention 共 7 个源文件使用这三类 helper;官方 demo(demos/src/Markdown/CustomSyntax/React/index.jsx)还示范了用 helper 编写自定义语法的写法——生态里的自定义扩展完全可能复用同一脆弱实现。这条枚举的完备性边界也要交代:宿主自定义 extension、注入的 marked 实例或二次封装可以增加新 tokenizer,其行为无法由 Tiptap 仓内源码穷尽,对具体宿主仍是待审计项。
本章的信任模型可以收成一句:库提供触发面,宿主决定暴露面。攻击者可控的 Markdown 要先跨过宿主的配置闸门(启用 Markdown 入口),再跨过输入闸门(请求体、导入、粘贴或持久化文档),最后以完整属性串的形态抵达链路末端——那里没有长度闸门在等它。

图 2|三条入口与信任边界:库提供触发面,宿主决定暴露面
3. 根因解剖 I:块属性路径的二次复杂度机制
3.1 五阶段流水线
parseAttributes(tiptap@v3.29.2 → packages/core/src/utilities/markdown/attributeUtils.ts:26-85)是一条五阶段流水线,脆弱性集中在其中两轮:
// tiptap@v3.29.2 → packages/core/src/utilities/markdown/attributeUtils.ts(节选,注释为本文所加)
export function parseAttributes(attrString: string): Record<string, any> { // :26
if (!attrString?.trim()) { // :27-29
return {}
}
const attributes: Record<string, any> = {} // :31
// 阶段 1:引号段 → 占位符(:34-38)
const quotedStrings: string[] = []
const tempString = attrString.replace(/["']([^"']*)["']/g, match => {
quotedStrings.push(match)
return `__QUOTED_${quotedStrings.length - 1}__`
})
// 阶段 2:class / ID(:41-52)—— (?:^|\s) 单字符锚定,线性
const classMatches = tempString.match(/(?:^|\s)\.([\w-]+)/g)
const idMatch = tempString.match(/(?:^|\s)#([\w-]+)/)
// 阶段 3:键值匹配(:54-55)—— 脆弱点一
const kvRegex = /([a-zA-Z][\w-]*)\s*=\s*(__QUOTED_\d+__)/g
const kvMatches = Array.from(tempString.matchAll(kvRegex))
// 阶段 4:清理(:66-69)—— :69 重复同一未锚定贪婪模式,脆弱点二
const cleanString = tempString
.replace(/(?:^|\s)\.([\w-]+)/g, '')
.replace(/(?:^|\s)#([\w-]+)/g, '')
.replace(/([a-zA-Z][\w-]*)\s*=\s*__QUOTED_\d+__/g, '')
// 阶段 5:布尔属性切分(:72-78)—— 线性
// cleanString.split(/\s+/) 中形如 /^[a-zA-Z][\w-]*$/ 的词 → attributes[word] = true
图 3|parseAttributes 五阶段流水线:脆弱性集中在阶段 3 与阶段 4 两轮
五个阶段在攻击输入上并不同罪。逐阶段审计如下(锚定结构来自源码,代价判定依据 3.2 节的引擎推演):
阶段 | 表达式 / 手法 | 结构特征 | 攻击输入上的代价 |
1 引号摘除(:34-38) |
| 未锚定,但内层 | 线性空转:payload 无引号 |
2 class/ID(:41-52) | `/(?:^ | \s).([\w-]+)/g | \s)#([\w-]+)/` |
3 键值匹配(:54-55) |
| 无锚定起点 × 量词后必需 | 脆弱点一:Θ(n²) |
4 清理(:66-69) |
| 同上 | 脆弱点二:同一输入再付一次 Θ(n²) |
5 布尔切分(:72-78) |
| 逐词 | 线性 |
这张表回答了"为什么恰好是这两轮":阶段 2 同样有贪婪量词 [\w-]+,但起点被 (?:^|\s) 收敛到空白边界,失败在量词启动前就发生;阶段 3/4 独有的组合是"无锚定起点 + 量词后必需 ="——两层线性恰好在这里相乘。第 6 章的两种修复也印证了这一点:安全阶段都具备左边界或无必需后缀两者之一,两条脆弱阶段两者皆无——tokenizer 与 (?:^|\s) 锚定消解的正是这个组合(6.1、6.2 节)。
先把阶段 1 的设计意图说清楚:把引号段整体摘出、替换为 __QUOTED_N__,防止值内容被后续 class/ID/键值语法误解析。这是正当的防御性设计,但它把"占位符命名空间"暴露给了攻击者可控输入,为同形伪造留下通道(3.4 节)。对攻击输入而言,这一阶段是线性空转:payload 不含引号,不产生任何真实占位符。
3.2 引擎级推演:一次返回 null 的搜索如何花掉 Θ(n²)
考虑无 =、由词字符([\w-])构成的长串输入。两条脆弱正则共享同一前缀结构 [a-zA-Z][\w-]*,后随必需的 \s*=\s*。按 ECMAScript 规范,非 sticky 正则在当前位置匹配失败后由 RegExpBuiltinExec 推进起始索引重试;贪婪量词 [\w-]* 由 RepeatMatcher 先消费到不能再消费,随后在 continuation 失败时逐字符退回。于是在每个合法起始点 i(payload 中每个 ASCII 字母的位置):
[a-zA-Z]消费位置i的一个字母,常数代价;[\w-]*贪婪消费到串尾,n-i-1次字符消费;\s*在串尾匹配空串,=失败——1 次后缀检查;[\w-]*每退回一个字符,\s*=重试一次;攻击串中没有空白和等号,每步失败,共约n-i次=检查;- 该起始点整体失败,搜索推进到下一位置。
单一起始点代价 Θ(n-i),合法起始点数 Θ(n),总代价:
W_block(n) = Θ(n) + Σ(i ∈ C) Θ(n-i) = Θ(n²)搜索空间是一个"每个起点对应一条逐字符缩短链"的三角形,不是组合树:

图 4|无匹配搜索的回溯三角形:起点数 × 每起点回退链长,全部发生在单次 RegExpExec 内部
用 3 字符输入 abc 走一遍:起点 0 上 [a-zA-Z] 吃掉 a,[\w-]* 吃掉 bc,串尾 = 失败;退回 c 失败、退回 b 失败、退到只剩 a 仍失败——3 次 = 检查;起点 1、2 依次是 2 次、1 次;合计 6 = 3×4/2 次。为什么是二次而不是指数:攻击路径上只有一个可变长度的量词在活跃([\w-]*),没有嵌套量词,\s* 与词字符不重叠、行内路径的引号分支在 = 失败后根本不可达——两层线性(起点数 × 每起点链长)相乘得 Θ(n²),不存在指数回溯的组合爆炸。
有一个容易误判的细节:全部二次工作发生在一次返回 null 的 RegExpExec 内部。matchAll 迭代器没有产出任何结果,清理 replace 也没有执行任何替换——把根因归咎于 matchAll 或 JavaScript 层的循环,会完全错过"逐起点搜索 × 起点内回退"这个真实机制。阶段 3(matchAll)与阶段 4(:69 的 replace)对同一个 tempString 各做一次完整的无匹配搜索,同一输入付两次二次代价。候选起点密度只改变常数:纯字母串 a^n 每个位置都是候选,= 检查约 0.5n²;官方 PoC 的同形串 P(r) = "__QUOTED_0"^r + "__QUOTED_0__"(n = 10r+12)里,每个 10 字符单元只有 QUOTED 的 6 个字母能作首字符(_ 和 0 只能被 [\w-]* 消费),候选起点 6(r+1) 个,= 检查精确为 30r²+75r+45,主项约 0.3n²。
3.3 计数与引擎证据
上述推演是规范语义层面的模型,字符检查数不是引擎指令数。两组独立证据支撑它:
等价计数模型(D2,容器内插桩):按状态链统计单次失败搜索的后缀尝试数,5/10/20 KiB 同形输入分别为 7,865,856 / 31,460,352 / 125,835,264,两次翻倍比 3.9996× / 3.9998×;10 桶分布显示候选起点近似均匀覆盖从短失败链到接近全长的失败链,不由少数离群起点主导。模型总量与完整 parseAttributes 耗时(16.954 / 64.045 / 252.164 ms)的三点 Pearson 相关系数 0.99999998——仅作同向描述,不作统计外推。
V8 内部 backtrack budget(D2,阈值二分):开启 --enable-experimental-regexp-engine-on-excessive-backtracks 后,对 --regexp-backtracks-before-fallback 做整数二分,三档的相邻边界为 784,896–897 / 3,142,656–657 / 12,576,768–769,翻倍比 4.0039× / 4.0020×。matchAll 与清理 replace 的边界在三档完全相同,两阶段隔离耗时比(matchAll∶replace)0.86 / 0.93 / 0.92——支持"近似 1:1"(replace 一致略慢),不写成精确恒定占比。budget 不是 CPU 指令数,绝对值约为模型计数的十分之一,两套计数单位不能按绝对值划等号;可靠的共同结论是二者独立呈现约 4× 翻倍、均支持 Θ(n²)。
这组开关本身有防御侧含义:--enable-experimental-regexp-engine-on-excessive-backtracks 是 V8 实验特性,必须经启动参数显式启用(D2 即通过子进程注入),语义是回溯超出 budget 时切换到非回溯的实验引擎执行。引擎层面确实存在超线性兜底的机制,但它不是默认部署能拿到的稳定防线——对未升级版本,防线只能落在库与调用方一侧。
3.4 payload 形态与占位符同形
官方 PoC 选了 __QUOTED_0 重复体,让攻击串与占位符形态同形。**形态对照实验(D1,等长输入、预热 1 次测 3 次取最小)**说明同形并不是必要条件:
等长属性串 | 3.29.2:5 KiB | 10 KiB | 20 KiB | 两次翻倍比 | 3.30.5:20 KiB |
| 16.699 ms | 61.970 ms | 241.575 ms | 3.71× / 3.90× | 0.176 ms |
纯字母长词 | 21.284 ms | 84.850 ms | 334.821 ms | 3.99× / 3.95× | 0.198 ms |
| 0.245 ms | 0.435 ms | 0.919 ms | 1.77× / 2.11× | 1.184 ms |
两个合法键值对 + 纯字母长词 | 23.262 ms | 94.624 ms | 378.582 ms | 4.07× / 4.00× | 0.233 ms |
任意无等号的纯字母长词同样二次增长,而且更慢(三档分别是同形串的 1.27/1.37/1.39 倍),方向与"候选起点更密"的静态预测一致;实测比例低于理论主项比 5/3,说明 V8 优化、其他线性阶段与固定成本仍在影响墙钟常数。前部放合法键值对也保护不了尾部长词。交替空格把最大词段切成单字符,每起点失败代价降为常数,整体近似线性——所以"形态无关"的确切含义是不依赖占位符字面,但必须保留长词段;密集空白会切断触发。官方 PoC 为什么选同形串?"伪装性"(同形串在日志或样本中更像解析器内部产物)是基于证据的推断,不能写成厂商动机。
再看占位符协议本身。阶段 3 匹配到占位符之后,还要把它引回阶段 1 登记的真实引号段:
// tiptap@v3.29.2 → packages/core/src/utilities/markdown/attributeUtils.ts:54-63(节选,中文注释为本文所加)
const kvRegex = /([a-zA-Z][\w-]*)\s*=\s*(__QUOTED_\d+__)/g
const kvMatches = Array.from(tempString.matchAll(kvRegex))
kvMatches.forEach(([, key, quotedRef]) => {
const quotedIndex = parseInt(quotedRef.match(/__QUOTED_(\d+)__/)?.[1] || '0', 10)
const quotedValue = quotedStrings[quotedIndex] // quotedStrings 只在阶段 1 摘除真实引号段时增长
if (quotedValue) {
// Remove the outer quotes
attributes[key] = quotedValue.slice(1, -1)
}
})还原查表依赖 quotedStrings[quotedIndex](:58)。攻击者可以直接书写 __QUOTED_0__ 文本而无对应引号段:此时 quotedIndex 解析为 0、quotedStrings[0] 为 undefined,if (quotedValue) 守卫使该属性被跳过——在没有真实引号段的输入(官方 PoC 即属此类)里,伪造不改变属性结果。但这个结论不能推广:输入一旦登记了真实引号段,伪造的引用就会按索引命中已登记的值并写入属性。按源码控制流推演(未单独动态复现),title=__QUOTED_0__ "forged" disabled 在 3.29.2 上解析为 {title: 'forged', disabled: true},修复版的回归测试对同一输入断言 {disabled: true}(tiptap@v3.30.5 → packages/core/__tests__/parseAttributes.spec.ts:26-29)。两种情形共同印证了一个设计隐患:用户输入与内部协议记号同形冲突。这个隐患的推广价值在第 8 章展开。
引入历史也在这一节收口:tiptap@35645d94(2025-10-14,"Markdown Support (#6821)")一次性引入 attributeUtils.ts(130 行)、createBlockMarkdownSpec.ts(225 行)、createInlineMarkdownSpec.ts(236 行)等全部解析器。这段代码从合入首日就是当前形态,没有中途演化出问题——缺陷是设计出来的,不是长出来的。
4. 根因解剖 II:行内 shortcode 路径与 Mention 的实现漂移
4.1 core 行内实现
core 的默认行内解析器是同一个模式的更简版本:
// tiptap@v3.29.2 → packages/core/src/utilities/markdown/createInlineMarkdownSpec.ts:13-30(完整函数,行号注释为本文所加)
function parseShortcodeAttributes(attrString: string): Record<string, any> {
if (!attrString.trim()) {
return {}
}
const attributes: Record<string, any> = {}
// Match key=value pairs, only accepting quoted values
const regex = /(\w+)=(?:"([^"]*)"|'([^']*)')/g // :20 未锚定;\w 含数字与 _
let match = regex.exec(attrString) // :21
while (match !== null) {
const [, key, doubleQuoted, singleQuoted] = match
attributes[key] = doubleQuoted || singleQuoted
match = regex.exec(attrString) // :26
}
return attributes
}对 [<name> <无等号长词串>] 输入(复现使用 0^n),每个位置都能作为 \w+ 的起点:起点 i 上 \w+ 贪婪消费剩余 n-i 个字符,= 失败后量词逐字符退回并重试,直到只保留一个字符仍失败。单起点代价 n-i,总代价 n(n+1)/2 = Θ(n²)——与块路径相同的三角形。输入中没有 =,双引号/单引号两个分支不可达,不存在分支组合爆炸;第一次 regex.exec() 就已在引擎内部试完全部起点并返回 null,while 循环体执行零次。若前部存在合法属性,成功匹配会把 lastIndex 推进到该匹配末尾,但最后一段无等号长词仍会在一次后续 exec 中产生与其长度平方同阶的工作——又是"单次引擎调用内部的二次",不是循环次数问题。
4.2 Mention 的副本与版本考古
Mention 没有复用 core 的内部函数,而是通过 helper 的 parseAttributes 选项注入了自己的实现:
// tiptap@v3.29.2 → packages/extension-mention/src/mention.ts:279-297(节选,行号注释为本文所加)
...createInlineMarkdownSpec({
nodeName: 'mention',
name: '@',
selfClosing: true,
allowedAttributes: ['id', 'label', { name: 'mentionSuggestionChar', skipIfDefault: '@' }],
parseAttributes: (attrString: string) => { // :284 注入自定义实现
const attrs: Record<string, any> = {}
const regex = /(\w+)=(?:"([^"]*)"|'([^']*)')/g // :286 与 core :20 同型拷贝
let match = regex.exec(attrString)
while (match !== null) {
const [, key, doubleQuoted, singleQuoted] = match
const value = doubleQuoted ?? singleQuoted
attrs[key === 'char' ? 'mentionSuggestionChar' : key] = value // char 键映射为 mentionSuggestionChar
match = regex.exec(attrString)
}
return attrs
},
})同一个漏洞模式,在两个包里各有一份独立演化的拷贝。npm 包静态考古(@tiptap/extension-mention)给出时间线:3.6.7 无 Markdown spec;3.7.0 首次接入并进入 core 的脆弱解析;3.30.4 仍自带未锚定 /\w+=…/g;3.30.5 改为锚定形态。这解释了一个元数据错位:修复 changeset 同时 patch core 与 extension-mention,而 GHSA 受影响包只列 core——依赖通告驱动的升级可能只升 core、漏掉 Mention。本地实测 32,772 B 输入下,Mention 扩展 3.29.2 耗时 371.46 ms、3.30.5 为 0.22 ms。"Mention 受影响区间 3.7.0–3.30.4"是本地工程结论,GHSA 未为该包发布独立公告,本文按此口径表述。
4.3 parseAttributes 回调注入的能力与边界
三类 helper(atom/container/inline)在漏洞版与修复版都把 parseAttributes 定义为可选回调,经解构默认值选择实现(tiptap@v3.29.2:atom 选项类型 createAtomBlockMarkdownSpec.ts:13-20、默认值 :63-67、调用点 :115-117;container createBlockMarkdownSpec.ts:14-22 / :65-70 / :133-134;inline createInlineMarkdownSpec.ts:60-68 / :147-152 / :249-250)。以行内 helper 为代表,三处机制在同一文件里是这样衔接的:
// tiptap@v3.29.2 → packages/core/src/utilities/markdown/createInlineMarkdownSpec.ts
// 同文件三处节选合并展示(省略号与序号注释为本文所加):
// ① 选项类型(:60-68):parseAttributes 是可选回调
export interface InlineMarkdownSpecOptions {
/** The Tiptap node name this spec is for */
nodeName: string
/** Function to parse attributes from the attribute string */
parseAttributes?: (attrString: string) => Record<string, any>
/* … */
}
export function createInlineMarkdownSpec(options: InlineMarkdownSpecOptions) {
// ② 解构默认值(:147-151):仅在未传或传 undefined 时落到 core 默认实现
const {
nodeName,
parseAttributes = parseShortcodeAttributes,
/* … */
} = options
/* …tokenizer 内部(:249-257)… */
// Parse attributes from the attribute string
const attributes = parseAttributes(attrString.trim()) // ③ 调用点(:250)
return {
type: nodeName,
raw: match[0],
content: content.trim(),
attributes, // 回调返回值直接成为 token attributes,没有回落分支
}
}JavaScript 解构默认值仅在属性为 undefined 时生效(②),显式回调优先;tokenizer 把回调结果直接赋给 token attributes(③),中间没有回落到默认解析器的分支。Docker 双版本共 6 个用例(哨兵返回值)都确认回调恰好调用 1 次、返回值原样进入 token。
这意味着宿主自行创建的 spec 可以传入线性实现,局部消除默认触发面。但边界同样明确:这不是 Manager 或包级全局开关;官方 Audio/Details/Twitch/YouTube/Mention 扩展在扩展定义内直接创建 spec(如 tiptap@v3.29.2 → extension-youtube/src/youtube.ts:395),标准 options 不转发该参数;宿主直接调用公开 parseAttributes 的路径 C 也不受影响。它是"宿主自创 spec 的源码级减损",不是不升级的等价修复(第 7 章给出可复制的保守示例)。
4.4 两条路径的机制对照
两条根因路径在第 3、4 章分别推演过,这里收束成一张对照表——共因在哪里、差异在哪里、修复为何长成两种样子(正则均指 tiptap@v3.29.2):
维度 | 块属性路径(第 3 章) | 行内 shortcode 路径(第 4 章) |
脆弱表达式 |
|
|
贪婪量词 |
|
|
必需后缀 |
|
|
候选起点 | ASCII 字母位置。纯数字核心串没有候选起点,块路径整体退化为线性——核心必须含线性数量的字母(同形串密度 0.6,纯字母 1.0,见 3.2 节) | 全部 |
二次代价发生处 | 两次独立的无匹配搜索(阶段 3 + 阶段 4),同一输入双倍付费 | 一次返回 null 的 |
回溯三角形 | 起点数 × 每起点回退链长 = Θ(n²)(3.2 节) | 同一个三角形(4.1 节) |
3.30.5 修复 | 连根删除两遍架构,前向 tokenizer(6.1 节) | core 行内并入统一 tokenizer;Mention 仅加 `(?:^ |
最有对照价值的是候选起点一行:同一个 Θ(n²) 三角形,对核心串的字符集要求却由首字符类的写法决定——块路径要求字母,行内路径连纯数字都接受。攻击输入的构造约束来自词法细节而非复杂度机制本身;8.2 节的 grep 规则取"词字符量词后随 ="为候选,恰好同时覆盖 \w+ 与 [\w-]* 两种首字符写法。
5. 从毫秒到不可用:攻击链、影响面与利用边界
5.1 计算放大原语
漏洞给攻击者的原语,是以数十 KB 输入换取数百毫秒至秒级同步 CPU 的放大能力,放大系数随输入二次增长。本地 1 CPU 容器实测:20.5 KB 块输入 263.9 ms、41 KB 944.0 ms;维护者公告给出的计时更高(约 20 KB 块 1.40 s、约 32 KB 行内 2.21 s,等长对照输入低于 1 ms)。公告未披露其测试所用 CPU、Node/V8 版本与采样方法,这组差异无法确切归因;能确认的是两组数据同向,不构成证据冲突。规模曲线(D4,精确字节输入、每档独立 Worker、4 KiB 预热、单次测量、60 s 硬超时):
精确块输入 | 3.29.2 | 3.30.5 |
64 KiB | 2,935.292 ms | 1.259 ms |
256 KiB | 45,554.531 ms | 3.141 ms |
1 MiB | 60 s 内未完成,被终止 | 10.148 ms |
4 MiB | —(停止条件,未运行) | 58.388 ms |
10 MiB | — | 114.973 ms |
输入扩大 4 倍、耗时扩大 15.5 倍,与二次增长一致;1 MiB 达到停止条件,未见栈或长度平台截断。修复版直到 10 MiB 仍是百毫秒级的线性增长。这个原语跨引擎成立(D3,5/10/20 KiB,预热后各测 3 次取最小):SpiderMonkey 102.15.1(CLI 固定源码转写)块/行内为 19.201→73.771→289.968 ms 与 11.785→46.205→173.886 ms;JavaScriptCore 26.0(WebKit 加载真实 npm 浏览器 bundle)为 20→85→356 ms 与 9→37→150 ms;四组翻倍比 3.76–4.25×。修复版在两引擎均约 2×(千次均摊)。Firefox 完整浏览器受容器 100 PIDs 限制无法启动,SpiderMonkey 是 CLI 口径,不写成 Firefox 页面复现。结论限定为:该二次模式在所测三个引擎家族上同向成立,不是 V8 特有行为。
5.2 同步阻塞语义决定影响形态
解析是同步调用,CPU 占用期间当前 JavaScript 执行线程不返回。同一个原语落在三种位置,产生三种影响形态。

图 5|同一同步解析原语在三种执行位置的影响形态(数据出自 D5、D6 与 5.3 节条目 5、6)
Node 主事件循环:攻击期间同进程所有请求排队。40,988 B 块输入解析约 959 ms,同期 /health(本身无重活)排队 945.0 ms;修复版同场景解析 0.70 ms、健康检查 2.35 ms。并发曲线(D5,2 CPU 容器,20,508 B 输入,1/2/4/8 并发、每档 3 轮,每 500 ms 采样健康延迟):
并发 | 3.29.2 单进程健康 P99 | 3.29.2 worker thread 主线程 P99 | 3.29.2 2 worker cluster P99 | 3.30.5 单进程 P99 |
1 | 257.218 ms | 2.140 ms | 2.187 ms | 2.390 ms |
2 | 532.156 ms | 3.946 ms | 261.749 ms | 2.242 ms |
4 | 1,044.159 ms | 3.482 ms | 549.128 ms | 2.227 ms |
8 | 1,992.620 ms | 3.272 ms | 1,069.473 ms | 2.120 ms |
P99 为每档 3–15 个健康样本的 nearest-rank 值,小样本下接近最大值,仅用于拓扑内对照。表里能读出三点:单进程延迟随排队深度近似线性叠加;worker thread 把主事件循环保护到 3.946 ms 以内,但单 worker 队列仍串行积压(并发 8 最慢任务 2,287.915 ms);2 worker/2 CPU cluster 把并发 4/8 的 P99 压到单进程的约 0.53/0.54,说明本次两进程对照提高了容量,但所有 worker 被攻击覆盖后同样退化。180 个攻击请求全部在 30 s 内成功返回,无 HTTP 失败、无 OOM——本拓扑证明的是延迟/SLO 退化与容量边界,不是"任意客户端必然超时"。
反向代理不能止损(D6,nginx 1.30.5,proxy_read_timeout 1s):65,538 B 用例的观测来自两次请求,时间线分开陈述。代理请求自身直接证明的是:t≈0 请求到达、Node 开始同步解析;t≈1.003 s nginx 向客户端返回 504,此刻同期发出的直连 /health(t≈0.17 s 发出)仍在排队未返回;此后后端并未停工——CPU 采样在 t≈1.58–2.58 s 持续 99.9–100.0%,/health 直到 t≈2.60 s 才返回(自身耗时 2,425.514 ms),即从 504 返回时刻起算,后端仍继续忙碌约 1,524.166 ms。同规模输入的解析时长由另一次独立发出的直连基线请求给出:容器内解析 2,475.128 ms,与上述忙碌窗口吻合。代理超时只限制客户端等待时间,不会取消已进入进程的同步计算,因而不是 CPU 缓解(该结论限定于本次 Node/nginx 拓扑)。修复版同规模经代理 3.326 ms 返回 200(解析 0.953 ms)。
浏览器主线程:Chromium 153.0.8010.52 无头实测(镜像构建号已记录在归档环境清单),页面内解析 314.5 ms(漏洞版)vs 1.7 ms(修复版),解析期间页面无法响应;持久化文档在容器重启后重新打开,再次触发主线程解析(360.3 ms)。修复版页内成本需要交代轮次差异:更早两轮 Docker Chromium 实验(当时镜像构建号未记录)曾测得 28.6/29.7 ms,本轮 1.7 ms 与 Node 侧同口径(约 1 ms)一致,旧值按未记录环境的历史轮次保留在复现文档中,不与本文数字混用。持久化变体是浏览器路径的现实放大器,实验口径要说准确:服务端(Docker named volume)保存的是文档记录,仅 kind 与 repeat 两个参数;每次打开链接时浏览器按这两个参数重新生成同一载荷并自动解析,实测容器重启后文档记录仍可读取并再次触发。实验没有把 Markdown 原文存入文档库再读回解析——"原文入库、打开时解析原文"的完整宿主形态属于 5.5 节的部署层推断,不是已验证事实。这把"一次性卡顿"变成"针对文档打开者的重复冻结"。
5.3 六个已验证条目
下表六项是复现计划中实际验证的条目,覆盖 A/B/C 三类触发入口和服务端、浏览器、worker 三类运行位置;它们不是两组维度的笛卡尔积,也不表示其余组合在具体宿主中必然可达。六项在隔离拓扑的最终代码版本上全量回归通过(以下数字即 2026-09-23 最终回归轮,原始输出按文件名对应 repro/results/2026-09-23-final-regression/ 中的 01- 至 08- 归档):
# | 触发路径 | 运行位置 | 关键观测(3.29.2 vs 3.30.5) |
1 | A 块属性 | Node 进程内 | 20,508 B:263.875 vs 1.095 ms;5,148/10,268/20,508 B 三档为 31.50/73.05/244.65 ms |
2 | B 行内/Mention | Node 进程内 | 32,772 B:371.459 vs 0.224 ms;8,196/16,388/32,772 B 三档为 30.12/96.22/367.40 ms |
3 | C 直接 | Node 进程内 | 20,492 B:251.459 vs 0.396 ms |
4 | A | 服务端事件循环 | 40,988 B: |
5 | A | 浏览器主线程(含持久化) | 页面解析 314.5 vs 1.7 ms;持久化文档重启后重开 360.3 ms 再次触发 |
6 | A | worker | 任务 1,187.784 vs 1.585 ms;同 worker ping 排队 1,168.014 vs 1.952 ms;主线程 |
5.4 最小复现闭环
上面的条目都有配套脚本与完整命令(随文附件 repro/README.md)。这里给出一条随仓库附件可执行的最小复现流程:启动、双版本同场景对照、增长判据、清理,输出全部是 JSON。前置条件有三项:本机 Docker Engine 与 Docker Compose、宿主 Node.js(驱动脚本只用它向回环地址发 HTTP 请求,需原生 fetch,实验时为 24.20.0),以及首次构建时对 Docker Hub 与 npm registry 的网络访问(基础镜像与依赖在构建期拉取)。换言之,这套流程不是"文章单独转载后仅凭正文即可执行"的自包含方案——scripts/ 驱动脚本、src/ 应用与 locks/ lockfile 都必须从仓库 repro/ 目录获取。拓扑内两个应用容器分别固定 3.29.2(127.0.0.1:3292)与 3.30.5(127.0.0.1:3305),基于 digest 固定的 node:24.20.0-bookworm-slim(多架构 index sha256:ba849c60…95a71e),应用依赖按版本由 repro/locks/ 的 lockfile 经 npm ci 安装,限制 1 CPU/256 MiB/100 PIDs,端口只绑定回环地址。
cd repro
docker compose build && docker compose up -d
docker compose ps # vulnerable 与 fixed 两个容器应处于运行状态
# 双版本同场景单次对照(block、repeat=2048 → 解析器输入 20,508 B)
node scripts/probe.mjs http://127.0.0.1:3292 single block 2048
node scripts/probe.mjs http://127.0.0.1:3305 single block 2048
# 三档规模增长(5,148/10,268/20,508 B);修复版把端口换成 3305 再跑一遍
node scripts/scale.mjs http://127.0.0.1:3292 block
node scripts/scale.mjs http://127.0.0.1:3305 block
# 可选:再翻一档(20,508 → 40,988 B),翻倍比更接近 4
node scripts/scale.mjs http://127.0.0.1:3292 block 2048,4096
docker compose logs --no-color vulnerable fixed
docker compose down核对三类字段:version(应为 3.29.2 / 3.30.5,确认请求打到了预期容器;probe 的输出在 body 字段里,scale 一次输出一个 JSON 数组、数组中每个对象对应一档规模)、bytes(block repeat=2048 应为 20,508;scale 默认三档应为 5,148/10,268/20,508,确认放大的是同一构造的输入)、解析耗时(probe 的 elapsedMs 与 scale 的 parseMs 为容器内解析耗时,wallMs 含 HTTP 往返)。成功判据对应 5.1 的增长口径:① 同为 20,508 B 输入,漏洞版比修复版慢约两个数量级(本拓扑最终回归实测约 264 ms vs 1.1 ms);② 漏洞版输入翻倍、耗时翻倍比明显超过 2 且随规模趋近 4(默认三档实测 2.3×/3.3×,小输入档的固定开销稀释二次项;40,988 B 档约 3.7×;容器内同口径的 D1/D2 为 3.7–4.1×);③ 修复版三档均为毫秒级,增长近似线性(D3/D4 口径翻倍比约 2×)。绝对耗时随硬件与引擎版本浮动,判据看的是数量级差与增长比,不是绝对毫秒。
两条使用边界:所有请求只发往本机回环地址上的这套容器,不得把脚本指向任何第三方目标——它不是扫描器,20 KB 输入换取数百毫秒 CPU 的放大是真实代价;清理默认 docker compose down 即可,down -v 会连实验卷(含持久化文档数据)一并删除,仅在确要清空数据时显式使用。
5.5 现实威胁建模
把六个实验条目映射回宿主应用时,应按同一条因果链判定,而不是只搜 PoC 字面。下表的业务入口是典型宿主形态,不代表本文确认了任何特定第三方产品受影响:
攻击应用场景 | 前置条件 → 输入入口 → 利用路径 | 代码机制 → 根本原因 | 安全影响 → 利用边界 |
匿名/低权限可提交 Markdown 的服务端接口 | 受影响版本、启用 Markdown 且注册相关 tokenizer → 请求体、导入文件,或攻击者可影响且被宿主当作原始 Markdown 解析的生成文本 → A/B(宿主直接调用属性 API 时也可是 C)进入 Node.js 主事件循环 | 未锚定搜索枚举线性多起点 × 贪婪量词在每个起点线性回退 → 用全局搜索承担词法分析,把本应只进入一次的词段暴露为每字符一个候选起点 | 同进程请求排队、延迟和有效容量退化 → 必须同时满足接口可达、攻击者可控输入、主线程同步解析且前置限制不足;收紧提交权限/配额、较低输入上限、与 CPU 成本相匹配的限流或可终止 worker 都会改变闭合程度 |
可保存并分享的富文本文档 | 受影响版本/配置 + 文档写入能力 + 分享/分发可达性 + 他人打开 → 宿主文档库持久化的 Markdown 原文(部署层推断,实验口径见 5.2 节) → 页面在打开时经 A/B 于浏览器主线程同步解析 | 与服务端相同的二次搜索,只是执行位置改为 UI 主线程 → 复杂度错配位于共享解析库,不会因运行端改变 | 每次重新打开都可再次占用主线程 → 若只能让攻击者自己粘贴/打开,通常只是自我 DoS;跨用户影响还依赖分发给其他读者和目标用户的打开行为 |
用 worker 池处理不可信文档的导入、索引或批处理管道 | 受影响版本/配置 + 不可信文档进队 → 文档或任务队列 → A/B/C 于某个 worker 内同步解析 | 同一个二次原语占用 worker → worker 只改变隔离边界和排队位置,不改变解析复杂度 | 单个 worker 长时忙碌、同 worker 任务排队,池容量可被逐步覆盖 → 有界队列、单任务 CPU/墙钟预算和可终止/重建 worker 可把影响限制在单任务或少数 worker |
三个场景的差别不在 payload,而在"谁能写入、谁触发解析、CPU 成本由谁承担"。服务端实验中,D5 并发 8 时健康请求 P99 为 1,992.620 ms,而 180 个攻击请求全部返回 200:在该拓扑中,只看 HTTP 错误率会漏掉这次容量退化。浏览器实验证明了 314.5 ms 级主线程占用与持久化文档在容器重启后再次触发(按 5.2 节口径,持久化并读回的是文档参数,载荷由浏览器按参数重建);上一行的"持久化文档库中的 Markdown 原文"与"同一文档可影响多个打开者"都还需要宿主具备原文入库与共享/分发数据流,这是部署层推断,不是 Docker 实验直接观测。worker 实验中任务耗时 1,187.784 ms、同 worker ping 排队 1,168.014 ms,但主线程健康请求仍为 5.082 ms:这正是"影响被隔离"与"计算已被取消"之间的差别。
攻击输入本身不要求任何语法侥幸:语法完整、闭合块、占位符字面都不是必要条件。三条路径的最小触发形态与复现口径的精确字节构成如下(构造函数见随文附件 repro/src/payloads.mjs,核心串 = 无 = 的长词段):
路径 | 触发形态 | 复现构成 | 精确字节 |
A(atom) |
| 包裹 16 B + | 20,508 |
A(container) |
| 属性解析先于闭合校验(2.2 节源码事实,未单独动态复现) | 核心 20,492 B 即达 |
B(Mention) |
|
| 32,772 |
C(直接 API) | 核心串直接传入 |
| 20,492 |
把触发条件压到最简,只剩两条结构要求:核心串是不被空白切断的长词段(D1:交替空格恢复近似线性);块路径的核心需要线性数量的 ASCII 字母作候选起点(4.4 节;行内路径纯数字即可)。
再补一笔攻击者经济学。以下数字全部由本拓扑数据推算,只示数量级结构,绝对值以生产硬件实测为准:
- 本拓扑中的饱和速率是个位数请求每秒。每次攻击请求触发 263.9 ms 解析(解析器输入 20,508 B、1 CPU 容器),意味着约 3.8 req/s 就能让单进程事件循环满载;D5 并发 8 的健康 P99 为 1,992.620 ms,约为 8 × 263.9 ms 的 0.94 倍,串行叠加模型在该实验点近似成立。这种放大不依赖高并发,仅按连接数或高并发阈值触发的防护可能不报警。
- 二次律使集中提交更具放大效率。一份 256 KiB 解析器输入在本机换取 45.55 s CPU,四份 64 KiB 输入合计只有 11.74 s——同样字节数,集中提交的放大收益约为分散的 3.9 倍(D4,解析器输入口径)。这只描述所测复杂度下的成本结构,不假定真实攻击者会选择某一输入形态。
- 较小请求体上限仍可能允许秒级消耗(部署情景推算,非实测)。先划清口径:本拓扑实验从不传输大请求体——驱动端只发约 15 B 的控制请求,长解析输入由容器内生成,因此下面对请求体限额的讨论是部署情景推算,不是本文实测;要把它变成实测结论,需另做真实请求体传输实验。真实部署中攻击输入必须经请求体送达:256 KiB 量级仍低于 nginx 默认 1 MiB 的
client_max_body_size;即使宿主把上限配置为 100 KiB,按 D4 已验证的二次增长估算也约为 7.2 s/请求(2.935 s × (100/64)²,解析器输入换算到请求体还需计入 JSON 等封装开销),对应的单核饱和速率约为 0.14 req/s。不同 CPU/JS 引擎与封装格式必须重测。这也说明限流阈值应与每次解析的 CPU 成本配对,不能沿用面向普通 I/O 请求的高频阈值。有效的非升级防线必须组合长度上限、速率限制与可终止隔离(7.2 节),任何单项都不构成修复。 - 仅靠错误率难以发现。请求体只是纯文本,本次 180 个攻击请求无一失败且未发生 OOM;显著信号是解析线程单核接近 100% 与延迟抬升。因此 7.3 节的检测把行为层(事件循环延迟、长任务、CPU)与错误率结合,而不把 HTTP 失败当成必要信号。
5.6 利用边界
把边界说清楚,防止影响被夸大。完整闭合需要同时满足:版本(实际解析到 >=3.7.0,<3.30.5,以锁文件/SBOM 为准;使用 Mention 还需核对扩展版本)+ 配置(启用 Markdown 入口、注册相关扩展、未用回调替换默认解析器)+ 输入控制(攻击者可影响完整属性串,且未被长度/语法过滤在解析前移除)+ 网络与认证(接口可达;匿名利用仅在接口匿名开放时成立,CVSS 的 PR:N 不能替代该项核查)+ 影响扩大条件(解析运行于关键主线程,或攻击者可重复/并行提交,且没有足以吸收攻击的隔离、限流或严格输入上限)。
不成立或价值有限的情形:纯 JSON 内容、未启用 Markdown 的应用不走该路径;宿主自创 spec 且 parseAttributes 回调被线性实现完整替换的,不走该 spec 的默认解析器(但该替换不覆盖官方扩展的硬编码 spec 与路径 C);反向代理超时只限制客户等待(D6)。能力上限是可用性破坏与 CPU 资源消耗:无 RCE、无越权、无泄露证据,按否定性证据缺失表述,不写成数学证明。公开 PoC 与真实攻击环境的差异也要指出:官方与本文的验证都止步于性能退化复现,未找到任何在野利用报告、真实产品端点或 IoC——本文不虚构这些。
外推同样要设限:D5 的并发退化与容量边界产生于 2 CPU、≤8 并发、每档 3 轮的本地拓扑;真实生产的负载均衡、限流、SLO/超时、队列与 worker 池策略决定最终影响形态,需按部署复测。文中所有服务级结论按条件性影响表述。
6. 修复解剖:一个漏洞,两种手法
修复提交 tiptap@d0d499be(2026-08-26,"Merge commit from fork")与同日的 8aea2ef14 具有相同作者时间和完全一致的 14 文件 diffstat(+421/−164),分别落入 release/v3 与 main——它们是同一补丁在两个 base 上的合并实例,不是两个独立修复(判断依据是提交拓扑与 diffstat;"经 GHSA 临时私有 Fork 协作修复"与提交标题形态高度一致,但公开公告 API 已不提供可核验的 private-fork 对象,保留为高置信度推断)。补丁做了四件事:
- 整体删除 130 行
attributeUtils.ts——脆弱实现连根移除,不留兼容层; - 职责拆分:
parseAttributes.ts(48 行)/serializeAttributes.ts(42 行)/parseShortcodeAttributes.ts(13 行); - 新增 163 行
tokenizeAttributes.ts:显式类型 token(class/id/keyValue/boolean)、索引单向推进; - 回归测试:边界与伪造占位符用例(
parseAttributes.spec.ts+17、utilities.spec.ts+77、mentionMarkdown.spec.ts+48 行)。
还有一个容易忽略的点:修复同时废除了占位符协议本身。新 tokenizer 就地读取引号值,不再有"__QUOTED_N__ 中间表示",第 3.4 节的同形冲突随两遍架构一起消失。

图 6|一个漏洞,两种手法:结构性修复(tokenizer)与定点修复(锚定)对应同一共因
6.1 tokenizer 的线性上界
新实现的关键,是把"匹配失败后的回溯"替换为"跳到下一个空白边界":
// tiptap@v3.30.5 → packages/core/src/utilities/markdown/tokenizeAttributes.ts(节选,中文注释为本文所加)
function skipToWhitespace(input: string, index: number): number {
while (index < input.length && !isWhitespace(input[index])) {
index += 1
}
return index
}
export function tokenizeAttributes(input: string, syntax: AttributeSyntax): AttributeToken[] {
const tokens: AttributeToken[] = []
let index = 0
while (index < input.length) {
index = skipWhitespace(input, index)
if (index >= input.length) {
break
}
const result = readAttribute(input, index, syntax) // 按 ./#/引号/名称分派
if (result.token) {
tokens.push(result.token)
}
index = result.nextIndex // 每轮严格前进:所有分支保证 nextIndex > index
}
return tokens
}
// tiptap@v3.30.5 → packages/core/src/utilities/markdown/parseAttributes.ts:42-48
export function parseAttributes(attrString: string): Record<string, any> {
if (!attrString?.trim()) {
return {}
}
return applyTokens(tokenizeAttributes(attrString, 'pandoc'))
}线性性不是直觉,是可以逐分支验证的不变式(S4 审计):进入 readAttribute(input, i, …) 的每个分支都保证 nextIndex > i——readName 从 i+1 起读;readShorthand 即使名称为空也从 skipToWhitespace 前进;readQuotedSegment 未找到闭合引号时从当前引号扫到下一空白或末尾;非法首字符由 skipToWhitespace 消费至少一位。总扫描量上,正常读取区间互不重叠;Pandoc 键值失败时的空白重扫同段最多常数次;成功引号搜索后索引跳过闭合位(区间不重叠),失败尾扫每种引号最多一次——否则后面的同类引号早已成为前一次的闭合点。全部循环、尾扫与切片复制总长 O(n),输出空间 O(n)。
6.2 Mention 的锚定
Mention 的修复只有一处正则改动:
# tiptap@d0d499be → packages/extension-mention/src/mention.ts(上下文行即 4.2 节注入的回调)
...createInlineMarkdownSpec({
nodeName: 'mention',
name: '@',
selfClosing: true,
allowedAttributes: ['id', 'label', { name: 'mentionSuggestionChar', skipIfDefault: '@' }],
parseAttributes: (attrString: string) => {
const attrs: Record<string, any> = {}
- const regex = /(\w+)=(?:"([^"]*)"|'([^']*)')/g
+ const regex = /(?:^|\s)(\w+)=(?:"([^"]*)"|'([^']*)')/g
let match = regex.exec(attrString)
while (match !== null) {锚定后,引擎仍可在每个位置尝试,但只有字符串开头或空白后才能进入 \w+ 的长词扫描;连续空白中只有紧邻词的最后一个位置有效,各最大词段只被进入一次,总扫描线性。这正是第 3 章三角形的直接消解:把 Θ(n) 个候选起点收敛为每个词段一个。
两种手法的对比是本案修复最有教学价值的部分:tokenizer 是结构性修复——重写解析架构、顺带消除占位符协议,代价是 163 行新代码与语义面重验;锚定是定点修复——一个 (?:^|\s) 前缀消解多起点重扫,代价近乎零,但保留了正则引擎的执行模型与 exec 循环。两者分别适用于"缺陷深植于架构"与"缺陷孤立于表达式"两种情形;判断用哪种,前提是先完成 3.2 节那样的机制定位。
往根因再收一层:两种手法消解的是同一个错配——用未锚定全局搜索承担词法分析职责。搜索语义把串内每个位置都视为合法起点(Θ(n) 个),而属性切分只需要每个词段一个起点;tokenizer 以索引单向推进直接实现后者,锚定以左边界把有效起点收敛到词段首。两条路径、两个包、两次独立实现犯下同一个错误,原因也在这里:在正则里写"键名 = 值"太自然,而搜索语义与词法分析语义之间的复杂度差,不会被只校验解析结果的功能测试暴露。8.2 节的可推广候选回收规则,正是按这个共因提炼的。
6.3 对抗验证与修复后回归
对两个修复点做对抗性检验(S4,9 类输入:长名、非法 shorthand、两类未闭合引号、Unicode 空白与 \w 差集、非 ASCII 起点、Mention 无等号长词/未闭合值/空白边界;每档预热 1 次、3 组批次取最小):
场景(3.30.5) | 64 KiB | 256 KiB | 1 MiB | 两次 4× 放大的耗时比 |
Pandoc 长名称 | 0.623 ms | 2.522 ms | 10.013 ms | 4.05× / 3.97× |
Pandoc 未闭合键值 | 0.521 ms | 2.105 ms | 8.525 ms | 4.04× / 4.05× |
Pandoc Unicode 空白失败重扫 | 4.72 ms | 18.872 ms | 73.829 ms | 4.00× / 3.91× |
Mention 无等号长词 | 0.072 ms | 0.287 ms | 1.156 ms | 3.99× / 4.02× |
Mention 未闭合键值 | 0.092 ms | 0.422 ms | 1.396 ms | 4.61× / 3.31× |
参数每扩 4 倍、耗时大致扩 4 倍,与 O(n) 一致(Unicode 场景实际字节数高于参数,绝对值按实际输入解读)。静态上界与动态增长互相印证:在已检查的属性语法与 1 MiB 参数范围内,未发现两个公告修复点的超线性绕过。这不是对整个 Markdown 管线所有正则的不存在性证明——本审计不能证明整个 Markdown 管线不存在其他问题,上述否定性结论的检查范围以两个公告修复点为限。
修复后的演化也在监控范围内(S6):v3.30.5..8623fbe847(HEAD,2026-09-17)三个相关目录 12 个历史提交、48 个净变更文件逐 hunk 审阅——修复核心(tokenizeAttributes.ts、两个 parse 入口、Mention 属性正则)无净变化;5c7200d11 新增的 atom 缩进正则保持行首锚定与有界空格量词;序列化端的列宽与 mark 处理未引入依赖输入长度的重试环。未发现修复回退或新超线性路径。一个元数据细节:该 HEAD 快照的包版本字段仍是 3.30.3(合并历史带入),因此只把它当固定 commit 快照审计,处置以 npm 发布版与锁文件中的实际版本为准(阈值 >=3.30.5)。
修复效果的最终口径:固定输入下 3.29.2 vs 3.30.5 全部降至亚毫秒到毫秒级(263.875→1.095、371.459→0.224、251.459→0.396 ms;D4 曲线见 5.1 节)。
7. 检测、缓解与修复
7.1 修复与版本对齐
升级 @tiptap/core 至 ≥3.30.5,并核对锁文件/SBOM 中实际解析到的版本,而不是 package.json 的声明范围。Tiptap 随上层软件传递引入时,升级上层软件或按其官方说明更新依赖版本。关键补充:全家桶版本对齐——Mention 3.30.4 仍带自己的脆弱实现而 GHSA 不列该包,只升 core 的部署可能保留一条完整的行内触发路径。
7.2 缓解
官方未提供不升级的部署级 workaround,以下为分析建议并按此标注:
- 入口隔离:无法立即升级时,把不可信 Markdown 暂时移出相关解析路径(禁用导入/预览功能或前端入口)。注意第 2 章的边界:语法层过滤必须同时覆盖已注册扩展名称在 atom 与 container 下的两种开始形态、跨行空白与跨行属性,仅匹配官方 PoC 的闭合字面不足以阻断;更稳妥的仍是升级或入口关闭。
- 输入长度限制:限制请求体/导入大小可以降低影响,但 D4 表明 64 KiB 级解析器输入在本机已可同步阻塞约 2.9 s(实验中该输入由容器内生成;请求体限额的部署情景推算见 5.5 节);即使宿主把上限配置为 100 KiB,按 5.5 节的二次趋势估算仍是秒级/请求,且饱和所需速率降到每秒零点几次——限制是纵深减损不是修复,须与速率限制、可终止隔离组合成边界;阈值需计入 JSON 等封装开销并按硬件/SLO 实测。
- 执行隔离:把解析移出主线程、进入可终止 worker。D5 证实 worker thread 保护主事件循环(P99 ≤3.946 ms),但需配合有界队列与可中止/重启策略,否则只是把积压从主线程搬到 worker 队列;cluster 提高容量,饱和点推迟到全部 worker 被占用之后。
- 速率限制与代理超时定位:限流约束攻击可持续性;D6 证实代理超时不取消进程内同步计算,只能限制客户等待,不能当 CPU 缓解汇报。
- 回调替换(源码级,仅限宿主自创 spec):用三类 helper 创建 spec 时显式传入单遍线性解析回调(API 依据见 4.3 节)。保守示例——只接受 ASCII 键名与引号值,遇到不支持的语法返回空属性:
import { createAtomBlockMarkdownSpec } from '@tiptap/core'
// 保守子集:索引只向前推进,每个引号搜索区间不重叠,总时间 O(n)。
// 不支持 Pandoc .class/#id/boolean shorthand、Unicode 键名与引号转义;
// 语法不匹配时仅返回空属性:token 仍会照常生成(见 4.3 节),不构成对该输入的拒绝;上线前必须核对应用语义。
function parseQuotedAttributes(input: string): Record<string, string> {
const attrs: Record<string, string> = Object.create(null)
const isName = (c: number) => /[A-Za-z0-9_-]/.test(String.fromCharCode(c))
const isSpace = (c: number) => c === 9 || c === 10 || c === 13 || c === 32
let i = 0
while (i < input.length) {
while (i < input.length && isSpace(input.charCodeAt(i))) i += 1
if (i === input.length) break
if (!/[A-Za-z]/.test(input[i]!)) return Object.create(null) // 键名必须 ASCII 字母起始
const keyStart = i
i += 1
while (i < input.length && isName(input.charCodeAt(i))) i += 1
const key = input.slice(keyStart, i)
while (i < input.length && isSpace(input.charCodeAt(i))) i += 1
if (input[i] !== '=') return Object.create(null)
i += 1
while (i < input.length && isSpace(input.charCodeAt(i))) i += 1
const quote = input[i]
if (quote !== '"' && quote !== "'") return Object.create(null)
const end = input.indexOf(quote, i + 1)
if (end < 0) return Object.create(null)
attrs[key] = input.slice(i + 1, end)
i = end + 1
if (i < input.length && !isSpace(input.charCodeAt(i))) return Object.create(null) // 值后必须是空白或结尾
}
return attrs
}
const spec = createAtomBlockMarkdownSpec({ nodeName: 'probe', parseAttributes: parseQuotedAttributes })再强调一次边界:该回调只保护宿主自行创建的 spec,官方扩展的标准 options 不转发此参数、直接调用 parseAttributes 的路径不受影响——它不是升级的等价替代。
7.3 检测
- 行为层:解析期间单线程 CPU 飙升、Node 事件循环延迟(
perf_hooks.monitorEventLoopDelay)、浏览器 Long Task API 长任务、worker 任务超时。这些是可用性异常的通用信号,非漏洞唯一证据。 - 静态层:锁文件/SBOM 中
@tiptap/core与@tiptap/extension-mention的实际版本 + 是否存在显式 Markdown 入口(contentType: 'markdown'、markdown.parse、MarkdownManager)+ 是否注册使用三类 helper 的扩展(含官方 youtube/audio/twitch/details/mention 与自定义扩展)。 - 输入层:异常长、无键值边界的块属性或 shortcode 属性段。与
__QUOTED_\d+__同形的属性串是官方 PoC 的特异线索,但不宜作为唯一规则:D1 已证明等长纯字母长词同样触发且更慢(5/10/20 KiB 三档分别约 1.27、1.37、1.39 倍),改写字符即可绕过占位符签名——规则应针对"属性段内异常长的无分隔词段"这一结构特征,而非任何字面量(来自第 3 章机制分析,未在实际 WAF/监控产品中验证规则可行性,待验证)。 - 供应链层:无 CVE 意味着仅以 CVE 号为主键的 SCA 规则会静默;对 GHSA-only 漏洞的覆盖(OSV/GHSA 数据源是否接入)值得作为依赖治理的自查项。
- 影响自查(仅对自有且已获授权测试的系统执行):这类输入会真实放大 CPU 占用,应在与生产及其他用户隔离、容量有界的专用测试环境进行——共享的预发环境若承接真实流量或他人使用,不适合作为投放目标。用良性形态的等价输入——例如 D1 口径的 20 KiB 纯字母属性段,不携带占位符特征——走一遍正常的导入/预览路径,观测解析耗时是否随输入翻倍接近四倍、事件循环延迟是否同步抬升。这不依赖 PoC 字面,检验的是机制本身是否存在。
在 npm 项目中可先做一轮不执行解析器的候选面盘点(pnpm/Yarn 项目用对应的 why 命令):
npm ls @tiptap/core @tiptap/extension-mention --all
rg -n "contentType\s*:\s*['\"]markdown['\"]|\.markdown\.parse\(|new\s+MarkdownManager\(" --glob '!node_modules/**' .
rg -n "create(?:AtomBlock|Block|Inline)MarkdownSpec|parseAttributes\(" --glob '!node_modules/**' .第一条确认实际依赖树与版本对齐,后两条只用来回收候选入口;没有字面命中不能证明不可达,因为封装层、动态配置和上层依赖可能隐藏直接调用。
静态检测器的能力边界有实验支撑(D7,Node.js 24.20.0 容器,三工具同批,含指数阳性对照 ^(a+)+$ 与 3.30.5 Mention 线性阴性对照):recheck@4.5.0 将两条目标精确判为二次多项式、阴性对照判为线性;safe-regex2@5.1.1 命中指数对照却把两条目标判 safe(量词深度启发式的盲区);redos-detector@6.1.4 对目标与线性对照都报警(为无绝对锚点模式自动加 [^]*? 前缀的降级引入假阳性)。结论:检测能力取决于检测模型——只识别嵌套量词/指数形态的工具会漏掉此类二次搜索,支持多项式分析的工具能准确发现它;任何工具结果都需用阴性对照与增长实验校准,"safe"结论与"有告警"都不能单独采信。
8. 工程教训与可推广模式
8.1 引入即全量,评审无复杂度议题
R1 核对的公开时间线:PR #6821 于 2025-08-15 创建,2025-10-14 13:33:40 UTC 合并为 35645d94;最终公开元数据 123 个提交、90 个文件(+6,341/−128)。当前 API 可枚举 4 次 review、17 条行级评论、25 条普通评论:2025-09-24 的 review 集中在导出命名与 API 形态;2025-09-29 作者公开说明三类 helper 让用户"无需自行处理 Regex"——评审者知道 tokenizer 里有正则,但讨论停留在易用性说明;2025-10-11 两轮 Copilot review(覆盖 71/74、72/75 个文件、15 条行级评论)对 createInlineMarkdownSpec.ts 只评未使用参数、对 createBlockMarkdownSpec.ts 只评赋值可读性,未触及 attributeUtils.ts 的最坏复杂度;2025-10-14 12:38 人工 APPROVED 讨论的是 bundle 体积与测试框架取舍。公开评审议题覆盖 API、体积、测试、可读性与功能正确性,唯独没有正则最坏复杂度、回溯、ReDoS 或 CPU DoS。
合并后约 8–11 分钟,v3.7.0 Release 与 npm 包先后发布;最终 head 的公开检查包含成功的 build、9 个测试 job 与 Netlify deploy,另有 2 个 neutral job,integration job 在合并约 76 秒后才结束、随后 release job 失败——功能测试通过,从来不等价于复杂度安全。
措辞上克制一点:PR 规模只能作为评审负载背景,"大 PR 导致漏审"是因果猜测,不应写成结论;否定性结论只覆盖当前公开记录,不能排除私下沟通或已删除内容。同一时间线的另一端:2026-08-26 11:14–11:15 双 base 补丁形成,11:27 npm 3.30.5 发布,11:41 维护者 GHSA 发布(Reporter credit:joostgrunwald,accepted),2026-09-08 全局 Advisory Reviewed。报告创建时间未公开,"报告到修复"的响应时长不可计算。
8.2 同型模式:机制可提炼,grep 回收候选
本案的机制核心可以提炼为三个条件的交集:未锚定的搜索语义 + 贪婪词段 + 词段后的必需后缀失败。第三个成分很容易归纳错,写成"g/matchAll/exec 从多个候选起点继续搜索"并不成立:按 ECMAScript 语义,一次非 sticky、未锚定的 RegExpExec 在当前起点失败后本就会推进起点逐个重试(3.2 节),与经由哪个 API 触发无关;g 标志只影响多次调用之间 lastIndex 的推进,不是二次复杂度的必要条件。本案清理阶段的 replace(attributeUtils.ts:69)本身就是脆弱调用,而 replace、match、search、test 与 exec/matchAll 一样,都能承载同一搜索语义。
复杂度不在字面里,机制本身不可 grep,可 grep 的只有调用形态与正则字面。因此 S7 的两阶段命令只是针对本仓库已知调用形态的候选回收启发式,不是通用完备的审计规则:第一阶段枚举的 exec/matchAll 未覆盖全部正则调用 API——本案的清理 replace 就不在第一阶段命中内,靠第二阶段按"贪婪词段后随 ="的正则字面候选兜住后才进入人工复核;把规则移植到其他工程时,要么把第一阶段扩成完整的搜索入口清单,要么以第二阶段的字面候选为主、第一阶段仅作定位辅助,并保留最后一步人工复核控制流——单独 grep \w+ 则会淹没在 URL、tag 等无关命中里。S7 审计使用的原始命令如下(ref 换成目标快照即可复现):
# 第一阶段:枚举全局搜索扫描点(排除测试目录)
git grep -n -E '\.matchAll\(|\.exec\(' v3.29.2 -- 'packages/**/*.ts' 'packages/**/*.js' \
':(exclude)packages/**/__tests__/**' ':(exclude)packages/**/tests/**' \
':(exclude)packages/**/*.test.ts' ':(exclude)packages/**/*.spec.ts'
# 第二阶段:词字符量词后随 = 的正则字面候选
git grep -n -E '/[^/]*(\\w\+|\[[^]]*\\w[^]]*\][*+])[^/]*=[^/]*/[dgimsuvy]*' \
v3.29.2 -- 'packages/**/*.ts' 'packages/**/*.js' \
':(exclude)packages/**/__tests__/**' ':(exclude)packages/**/tests/**' \
':(exclude)packages/**/*.test.ts' ':(exclude)packages/**/*.spec.ts'在 tiptap@v3.29.2 上,两阶段分别命中 19 行/15 文件与 6 行候选,真阳性恰好回收到本案已知的三套实现(core 块属性两轮、core 行内、Mention),其余命中为整串锚定的 YouTube URL 对照等非同型用法;HEAD 快照未发现修复后的同型残留。对三个外部实现的固定 commit 对照(markdown-it-attrs 2283317039、prosemirror-markdown 6b95bfe7b1、pandoc 840753aac9)也未发现同型正则——三者分别使用前向索引循环、消费 markdown-it token 的单遍转换、parser combinator。这条否定性结论只覆盖所定义模式与固定快照,不等价于三个项目不存在其他复杂度问题。
先例方面(R2,仅采官方公告与可核验修复提交):未找到 Markdown/编辑器属性键值解析的公开同域同型公告;但存在一个跨领域的精确结构先例——@octokit/endpoint GHSA-x4c5-c7rf-jjgv(2025-02-14,早于 Tiptap Markdown 引入)的 /[\w-]+(?=-preview)/g 与本案同机制:长词段后的必需后缀失败、未锚定全局搜索从密集起点重扫;官方修复 6c9c5be 增加 (?<![\w-]) 左边界,与 Mention 的 (?:^|\s) 锚定同构。
Marked(underscore ReDoS、block.def 二次回溯、inline.reflinkSearch 指数回溯)、CKEditor 5 Markdown GFM(CVE-2021-21254)、simple-markdown(GHSL-2020-299)与 markdown-it 多条公告作为同域不同型旁证:证明 Markdown 解析器的正则复杂度风险早有公开记录,但根因分别是分支歧义、字符串重建或调用层重复扫描,不是属性键值模式。可成立的推广结论是:"为贪婪词段补充合法左边界/锚"是一条跨领域已落地的修复原则,本案模式值得作为审计规则推广;不可成立的是"该模式在 Markdown 属性生态普遍存在"或"Tiptap 是首个公开归纳该模式的项目"。
8.3 占位符命名空间与责任分界
__QUOTED_N__ 的教训有一般性:内部协议记号一旦与用户输入共享命名空间,攻击者就能伪造同形文本——伪造的后果取决于输入里有无真实引号段:官方 PoC 这类无引号段输入中,伪造只得到 undefined、被守卫跳过,不改变属性结果;输入登记了真实引号段时,伪造的引用会命中已登记的值并改写属性结果(3.4 节)。这个隐患上可核验的事实只有两条:官方 PoC 与内部占位符形态同形(报告者为何选这一形态只能推断,见 3.4 节);新 tokenizer 在消除二次扫描的同时,也客观上移除了这套占位符协议(第 6 章)。占位符是否影响了 PoC 的选择或修复的取舍,公开证据无法证明,本文不作这类因果归因。设计内部记号时应使其真正脱离用户输入的可达字符空间:转义(让记号字面无法由未转义的用户输入拼出)或结构化中间表示是可靠形态;单独的引号包裹不构成隔离——引号字符本身用户就能书写,本案阶段 1 登记的恰恰是用户输入的引号段。
责任分界是库级 DoS 的另一面:库提供触发面,宿主决定暴露面,两者各占一半。CVSS 的 PR:N/UI:N 描述的是通用评分场景,不能替代对具体宿主的数据流审计——第 2 章的配置闸门、第 5 章的五个闭合条件,才是判断"我的系统是否受影响"的实际清单。
8.4 结语
把全文收拢成四个问题。为什么成立:两条路径共享同一个引擎级机制——未锚定搜索在线性多个起点上重试,每个起点内贪婪量词又以线性长度回退寻找必需后缀,两层线性相乘为 Θ(n²);按第 3、4 章对这两条具体表达式的候选起点与回退链分析,复杂度止步于二次("无嵌套量词"本身不能排除更高阶多项式,不足以单独作为上界理由),但同步阻塞语义已足以把毫秒放大成不可用。何时可用:版本、配置、输入控制、网络/认证、影响扩大五类条件同时闭合。阻断手段在这条链上分两层,不能混用:JSON-only、禁用 Markdown、升级、以线性实现完整替换脆弱解析器,消除的是具体触发路径;强限流、长度限制、可终止 worker/cluster 隔离降低的是影响或改变故障边界,漏洞本身仍可触发,不能当修复汇报(7.2 节、D5/D6)。影响多大:条件闭合下,浏览器主线程冻结、事件循环排队、worker 占用与持久化重复触发均有量化数据;上限是可用性,无 RCE/越权/泄露证据。如何阻断:升级并对齐全家桶版本(≥3.30.5)是根治;入口闸门、长度限制、可终止隔离是减损;(?:^|\s) 式锚定与前向 tokenizer 给出两种修复范式;"未锚定搜索 + 贪婪词段 + 必需后缀失败"的同型候选回收(8.2 节)应进入常规审计清单。一段语义上完全正确的正则代码,可以在一次大版本 PR 中安静地潜伏十个月——把这个模式变成评审与扫描的默认议题,才是这类公告真正想留下的东西。
参考资料
- 维护者公告:Quadratic ReDoS in block and inline Markdown attribute parsing(GHSA-j95f-988m-3j2f),2026-08-26
- GitHub Reviewed 公告(版本范围、CVSS v4.0 8.7、CWE):advisories/GHSA-j95f-988m-3j2f
- 引入 PR:#6821 Markdown Support(合并提交
35645d94ae9cd73448a564104c2e08f64e9564bc,2025-10-14);v3.7.0 Release - 修复提交
d0d499be3cce633cf54ca9aa9f3d8a5a1f98bd74(同补丁双 base 合并8aea2ef142db9380cb640d5c080e6c569d068889);v3.30.5 Release;修复后 tokenizer:tokenizeAttributes.ts@d0d499be - 本地固定 refs:
v3.7.0=dab80e94c05df79d38784bbb09f0d81ceb369483、v3.29.2=5158212970344952dd9918b6a44bfb400d7fb6c1、v3.30.4=55f59e4907b37e0aa4404c52e965014f66158393、v3.30.5=b0c188b1bc919b204beace074de36f5cb2a2d961、HEAD=`8623fbe8478132aa0af7c8448a18b9a6ad080d8c`(完整克隆,2026-09-21) - Tiptap 官方文档:Markdown Editor API(contentType 默认值)、Basic Usage、Utilities(三类 helper)
- marked 源码引用(
@tiptap/markdown声明^17.0.1,实验拓扑实际解析 17.0.6 并由repro/locks/固定;行号引用按 v17.0.1 tag):Lexer.ts@v17.0.1 - 正则语义依据(第 3 章匹配推进与贪婪回退推演的规范锚点):ECMAScript 2026 RegExpBuiltinExec、RepeatMatcher、Symbol.matchAll、Symbol.replace
- 术语与分类:MITRE CWE-1333、CWE-400;ReDoSHunter(USENIX Security 2021)、RegexScalpel(USENIX Security 2022)
- 评分口径:FIRST CVSS v4 FAQ
- 结构同型先例:
@octokit/endpointGHSA-x4c5-c7rf-jjgv(2025-02-14)及修复提交6c9c5be - 同域不同型旁证:Marked Security Advisories、CKEditor 5 GHSA-hgmg-hhc8-g5wr、simple-markdown GHSL-2020-299
- 镜像与数据源:OSV GHSA-j95f-988m-3j2f、GitLab Advisory Database(GHSA 数据镜像,不作独立复现计)
动手复现的操作入口是随文附件
repro/README.md:镜像构建、三类入口探测、D1–D7/S3–S5 各实验与清理命令均在其中;清理用docker compose down即可,持久化实验卷需显式down -v才会移除。