0xmrma/CVE-2026-34212
GitHub: 0xmrma/CVE-2026-34212
该项目记录并复现了 Docmost 文档平台附件节点中由于 URL 清理不一致导致的高危存储型 XSS 漏洞(CVE-2026-34212)。
Stars: 0 | Forks: 0
# CVE-2026-34212
Docmost 接受附件节点内的 `javascript:` URL,在存储和渲染过程中将其原样保留,并将其转换回在 Docmost 源中可点击的锚点。
## 简介
我在 **Docmost**(开源协作文档平台)中发现、负责任地披露并复现了一个**高危存储型 XSS** 漏洞。
Docmost 的官方网站将其定位为一个企业级的本地部署 Wiki,拥有超过 **300 万次下载**,并表示它受到包括 **Vilnius City**、**Bechtle**、**Australian Government**、**Red Cross** 和 **ETS Quebec** 在内的组织团队的信任。
该漏洞隐藏在富文本系统中一个很容易被忽略的地方:
不在普通的链接扩展中,而是在用于文件附件的一个单独的自定义节点类型中。
我在审查编辑器 pipeline 时,脑海中有一个非常具体的问题:
**如果普通链接会阻止 `javascript:` URL,那么附件节点在到达锚点 sink 之前,是否会强制执行相同的规则?**
在存在漏洞的版本中,它们并没有。
Docmost 接受了页面 JSON 中的恶意附件节点,原封不动地存储了其 `url` 属性,随后将该值渲染回可点击的 `` 元素。
该问题成为了 **CVE-2026-34212**。
**Docmost:** [docmost/docmost](https://github.com/docmost/docmost)
**安全公告:** [GHSA-cf68-cff9-hq4w](https://github.com/docmost/docmost/security/advisories/GHSA-cf68-cff9-hq4w)
**CVE:** CVE-2026-34212
**修复版本:** `v0.71.0`
## 攻击链
`攻击者控制的附件节点 URL -> 接受并原样存储的页面 JSON -> HTML/React 渲染将该 URL 转换为锚点 href -> 受害者点击附件操作 -> 攻击者控制的 JavaScript 在 Docmost 源中执行`
## Docmost 这部分功能的作用
Docmost 以兼容 ProseMirror/Tiptap 的 JSON 格式存储页面内容。
该内容模型包含用于以下内容的自定义块节点:
- images
- diagrams
- embeds
- attachments
附件节点存储了以下字段:
- `url`
- `name`
- `mime`
- `size`
- `attachmentId`
服务器接受几种格式的页面内容:
- `json`
- `markdown`
- `html`
并在存储之前将其标准化为 ProseMirror JSON。
这意味着任何可以携带 URL 的节点类型都是直接信任边界的一部分。
如果这些节点类型之一最终渲染为 ``,那么 URL scheme 处理就不是可选的。
它是安全模型的一部分。
## 为什么这个攻击面值得研究
自定义编辑器扩展通常是安全策略偏移的常见源头。
基础系统可能已经知道如何正确处理危险的 URL,但每个自定义节点仍必须在其自身的 sink 处重新应用相同的规则。
这就产生了一种可预测的审查策略:
- 找到每一个存储类 URL 字段的节点类型
- 追踪该字段是在哪里被接收的
- 追踪该字段是在哪里被渲染的
- 将其清理行为与平台的正常链接处理进行比较
这正是暴露此漏洞的原因。
Docmost 的常规链接扩展已经将 `javascript:` 视为危险内容。
但其附件节点却没有。
一旦你看到这种不对称性,安全问题就变得显而易见了:
**我能否持久化一个 `url` 为 `javascript:` 的附件节点,并让它被渲染回活动的锚点中?**
答案是肯定的。
## 根本原因
根本原因是**各内容节点类型的 URL 清理不一致**。
只要整体内容符合 ProseMirror schema,服务器端的内容路径就会接受任意的附件 URL。
在存在漏洞的版本中:
- `CreatePageDto` 接受 `content?: string | object`
- `PageService.parseProsemirrorContent()` 标准化 `markdown`、`html` 或 `json`
- 服务器随后调用 `jsonToNode(prosemirrorJson)`
- 如果 schema 验证通过,就会存储内容
该验证步骤检查的是结构有效性,而不是 URL 安全性。
易受攻击服务器逻辑的关键部分实际上如下:
```
prosemirrorJson = content;
jsonToNode(prosemirrorJson);
return prosemirrorJson;
```
那里没有进行任何附件 URL scheme 标准化。
随后,附件扩展直接渲染了攻击者控制的值。
存在漏洞的附件节点执行了此操作:
```
url: {
default: "",
parseHTML: (element) => element.getAttribute("data-attachment-url"),
renderHTML: (attributes) => ({
"data-attachment-url": attributes.url,
}),
},
```
然后是:
```
[
"a",
{
href: HTMLAttributes["data-attachment-url"],
class: "attachment",
target: "blank",
},
`${HTMLAttributes["data-attachment-name"]}`,
]
```
在客户端,React 节点视图再次将其包装在:
```
```
但 `getFileUrl()` 仅对以下情况进行了特殊处理:
- 绝对 `http` URL
- `/api/...`
- `/files/...`
任何其他内容都会原样返回。
因此,像这样的 payload:
```
javascript:alert(document.domain)
```
存活了下来:
- JSON 存储
- 服务器端 schema 验证
- HTML 渲染
- 客户端 URL 处理
仅此一项就足以构成存储型 XSS。
让根本原因尤为清晰的是对比参考。
Docmost 的常规链接扩展明确阻止了 `javascript:`:
- 它在 `parseHTML()` 中拒绝了 `javascript:`
- 它在 `renderHTML()` 中将 `javascript:` href 置空
所以该产品早就知道这个 scheme 是危险的。
附件节点只是未能应用相同的策略。
这就是为什么这不是“编辑器中的通用 XSS”的原因。
这是一个特定于节点的信任边界漏洞。
## 为什么这是一个安全问题,而不仅仅是缺少清理
这个漏洞不仅仅与不安全的 HTML 美观度有关。
它允许能够编辑页面的攻击者持久化一个恶意 payload,当其他用户与渲染后的附件交互时,该 payload 稍后会在 Docmost 源中执行。
这很重要,因为同源脚本可以:
- 读取受害者可以访问的数据
- 以受害者的身份发起经过身份验证的请求
- 修改受害者被允许修改的内容
- 滥用任何暴露给该会话的 DOM 或 API 表面
点击要求并不会将其降级为一个微不足道的问题。
点击是正常产品行为的一部分:
UI 有意将附件呈现为一个可操作的链接/图标。
所以安全问题不是“攻击者能在没有任何交互的情况下强制执行任意 JS 吗?”
真正的问题是:
**应用程序是否会存储攻击者控制的、携带脚本的内容,并在随后将其作为受信任的交互路径呈现给其他用户?**
在存在漏洞的版本中,确实如此。
这就是存储型 XSS。
## 为什么利用是切实可行的
漏洞利用路径非常直接:
- 任何拥有页面编辑权限的用户都可以植入 payload
- 恶意 URL 在存储过程中保持不变
- 页面正常渲染
- 查看者只需要对页面的标准访问权限
- 只需点击一次附件操作即可触发执行
这也使得高权限用户成为了现实的目标。
如果工作空间所有者、管理员或广泛受信任的编辑者查看了攻击者控制的内容,并点击了附件操作,攻击者的脚本就会在权限更高的会话上下文中运行。
这才是重要的实际要点:
攻击者的权限要求仅为**低级**。
受害者的权限级别决定了 XSS 会话具有多少价值。
## 概念验证
我在 **Docmost `v0.70.3`** 上验证了该问题。
PoC 仅使用了常规的 HTTP 请求和应用程序自身的页面 API。
流程如下:
1. 以具有页面编辑权限的用户身份登录。
2. 创建或选择一个页面。
3. 发送 `POST /api/pages/update`,带有 `format: "json"` 以及一个 `url` 为 `javascript:` payload 的附件节点。
4. 通过 `POST /api/pages/info` 请求取回页面。
5. 确认存储的 JSON 仍然包含恶意 URL。
6. 以 HTML 格式请求同一页面,确认服务器返回的锚点的 `href` 仍然是 `javascript:...`。
7. 在 UI 中,查看者点击渲染的附件操作,即在 Docmost 源中执行 payload。
最小化的恶意内容如下:
```
{
"pageId": "",
"content": {
"type": "doc",
"content": [
{
"type": "attachment",
"attrs": {
"url": "javascript:alert(document.domain)",
"name": "policy.pdf",
"mime": "application/pdf",
"size": 1
}
}
]
},
"operation": "replace",
"format": "json"
}
```
我测试中观察到的实际结果是:
- API 原样接受了恶意附件节点
- 存储的页面 ID 为 `019d18cf-4212-70b0-894a-fe20080fb0f1`
- `POST /api/pages/info` 返回的存储 JSON 包含:
```
"url": "javascript:alert(document.domain)"
```
- 带有 `format: "html"` 的 `POST /api/pages/info` 返回的 HTML 包含:
```
```
那个 HTML 响应是关键证据。
我不需要依赖“浏览器可能会做出一些有趣行为”这种含糊其辞的说法。
应用程序本身渲染出了确切的执行 sink。
一旦用户点击该附件链接/图标,浏览器就会在创建该 URL 的页面源中执行 `javascript:` URL。
## 为什么选择这种方式进行 PoC
对于编辑器驱动的 XSS,仅靠截图是薄弱的证据。
它们展示的是症状,而不是边界失效。
这就是为什么我围绕两个明确的检查点构建了 PoC:
1. **存储证明**
2. **渲染 sink 证明**
存储证明表明服务器接受并保留了危险 scheme。
渲染 sink 证明表明应用程序将该存储的值重新转换为:
```
```
这种区分很重要。
如果产品存储了危险输入,但在到达每个 sink 之前将其中和,你可能存在一个强化差距,但不一定是实际发生的 XSS。
如果产品存储了危险输入,并随后将其渲染到真实的执行 sink 中,你就拥有了一个完整的漏洞利用链。
这里发生的情况正是如此。
## 修复分析
**`v0.71.0`** 中发布的修复通过对附件 URL 应用 URL 清理,解决了渲染出的漏洞利用路径。
附件扩展现在导入并使用 `sanitizeUrl`,包括:
- 在解析期间清理 `data-attachment-url`
- 在渲染期间清理 `data-attachment-url`
- 清理锚点 `href`
从概念上讲,该补丁将附件节点从:
- 信任原始附件 URL
- 发出原始附件 URL
改变为:
- 在附件 URL 成为渲染节点的一部分之前对其进行标准化
客户端辅助程序 `getFileUrl()` 也进行了更新,以便未知的 scheme 不再不受影响地通过。
在修复后的版本中,回退路径返回 `sanitizeUrl(src)`,而不是原样返回 `src`。
这是该修复的重要组成部分,因为存在漏洞的设计有两个相互加剧的问题:
- 节点渲染了原始的 `href`
- 客户端回退将未知的 scheme 视为可接受的
该补丁消除了这两种假设。
这对于实时 XSS 路径是一个很好的修复,因为它使附件 URL 处理与编辑器其余部分的安全模型重新保持一致。
话虽如此,这里仍然有一个更广泛的强化教训:
此处的客户端或渲染时清理是必要的,但在页面创建/更新期间,服务器端对危险 scheme 的拒绝将是一个更强的恒定式。
最安全的长期模型是:
- 在接收时拒绝明显危险的 scheme
- 在渲染边界再次进行清理
纵深防御在富内容系统中至关重要。
## 重要的回归测试用例
为了实现长期覆盖,以下是最重要的测试用例:
- 包含 `attachment.attrs.url = "javascript:..."` 的 JSON 页面更新
- 包含 `data-attachment-url="javascript:..."` 的 HTML 导入
- 附件渲染绝不能发出 `href="javascript:..."`
- 客户端回退辅助程序绝不能原样返回未知的可执行 scheme
- 附件节点和普通链接节点应共享等效的 URL scheme 策略
- 诸如 `/api/files/...` 和 `/files/...` 等安全的内部附件路径应继续正常工作
关键点在于一致性。
如果普通链接被清理了,而自定义携带 URL 的节点却没有,那么编辑器实际上并没有单一的 URL 安全策略。
它只有碎片,而碎片正是 XSS 漏洞存在的地方。
## 严重性和分类
已发布的公告将此问题分类为:
- **CWE-79**: 网页生成期间输入的不正确中和
- **CVSS v3.1**:
```
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:N
```
评级为 **7.6 / High**。
这是一个合理的分类。
重要的属性包括:
- 较低的攻击者权限要求
- 存储型 payload
- 在 Docmost 源中执行
- 范围更改
- 重大机密性影响,因为脚本可以访问受害者可见的应用程序内数据
由于受害者必须激活附件链接/图标,因此仍需用户交互。
这就是为什么 `UI:R` 是正确的原因。
但是,一旦发生这种交互,安全边界早在此之前就已经失效了:
应用程序存储了危险的 scheme,并将其渲染回执行 sink 中。
## 披露
我通过 GitHub Security Advisories 私下报告了该问题,包含:
- 根本原因分析
- 实时 HTTP PoC
- 存储的 JSON 证据
- 渲染的 HTML sink 证据
- 固定的一次性测试实验室
该问题被接受,分配了 **CVE-2026-34212**,并于 **2026 年 4 月 14 日**发布。
目前的公开公告列出了:
- 受影响版本:`0.70.3`
- 修复版本:`0.71.0`
我的实时验证是在 `v0.70.3` 上进行的,与已发布的受影响版本相匹配。
## 这个漏洞实际教会了我们什么
这里的主要教训不仅仅是“清理 URL”。
大家都已经知道了。
更有趣的教训是:
富文本系统积累自定义扩展的速度往往快于积累安全审查的速度。
这恰恰造成了这种不对称性:
- 标准链接路径被强化了
- 附件路径被视为“内部的或“特殊的”
- 特殊路径悄然成为更容易的 XSS sink
这个漏洞还说明了为什么 schema 验证是不够的。
`jsonToNode()` 验证了内容是结构有效的 ProseMirror 数据。
它并不能证明内容渲染是安全的。
这是两个不同的问题。
当你将这些问题分开来看时,安全审查就会变得敏锐得多:
- 此内容在结构上有效吗?
- 此内容存储安全吗?
- 此内容在每个 sink 处渲染安全吗?
附件节点通过了第一个问题,但在第三个问题上失败了。
这就是存储的内容漏洞如何在其他结构良好的编辑器 pipeline 中存活下来的原因。
## 关键点
- Docmost 接受了页面内容中的原始附件节点 URL。
- 服务器端页面验证检查的是 ProseMirror schema 结构,而不是 URL scheme 安全性。
- 存在漏洞的附件节点直接根据攻击者控制的输入渲染了 `data-attachment-url` 和锚点 `href`。
- 客户端辅助程序 `getFileUrl()` 原样返回了未知的 scheme。
- 普通链接节点已经阻止了 `javascript:`,但附件节点却没有。
- 低权限编辑者可以一次性植入 payload,并在稍后针对查看者进行攻击。
- 实时 PoC 证明了存储持久性和渲染的可执行 sink。
- `v0.71.0` 中的修复向附件节点和客户端回退路径添加了 `sanitizeUrl` 处理。
## 结语
此漏洞与浏览器的怪癖无关。
它关乎一个绕过了应用程序自身 URL 安全假设的自定义内容节点。
Docmost 接受了一个攻击者控制的附件 URL,在存储过程中保留它,然后将其渲染回应用程序源中的活动锚点。
这就是为什么它成为了 **CVE-2026-34212**。
`v0.71.0` 中的补丁干净利落地关闭了活跃的 XSS 路径,但更广泛的教训是值得铭记的:
在重度依赖编辑器的应用程序中,每个可以携带 URL 的自定义节点都是其自身的安全边界,必须将其作为一个安全边界来进行审查。
## 攻击链
`攻击者控制的附件节点 URL -> 接受并原样存储的页面 JSON -> HTML/React 渲染将该 URL 转换为锚点 href -> 受害者点击附件操作 -> 攻击者控制的 JavaScript 在 Docmost 源中执行`
## Docmost 这部分功能的作用
Docmost 以兼容 ProseMirror/Tiptap 的 JSON 格式存储页面内容。
该内容模型包含用于以下内容的自定义块节点:
- images
- diagrams
- embeds
- attachments
附件节点存储了以下字段:
- `url`
- `name`
- `mime`
- `size`
- `attachmentId`
服务器接受几种格式的页面内容:
- `json`
- `markdown`
- `html`
并在存储之前将其标准化为 ProseMirror JSON。
这意味着任何可以携带 URL 的节点类型都是直接信任边界的一部分。
如果这些节点类型之一最终渲染为 ``,那么 URL scheme 处理就不是可选的。
它是安全模型的一部分。
## 为什么这个攻击面值得研究
自定义编辑器扩展通常是安全策略偏移的常见源头。
基础系统可能已经知道如何正确处理危险的 URL,但每个自定义节点仍必须在其自身的 sink 处重新应用相同的规则。
这就产生了一种可预测的审查策略:
- 找到每一个存储类 URL 字段的节点类型
- 追踪该字段是在哪里被接收的
- 追踪该字段是在哪里被渲染的
- 将其清理行为与平台的正常链接处理进行比较
这正是暴露此漏洞的原因。
Docmost 的常规链接扩展已经将 `javascript:` 视为危险内容。
但其附件节点却没有。
一旦你看到这种不对称性,安全问题就变得显而易见了:
**我能否持久化一个 `url` 为 `javascript:` 的附件节点,并让它被渲染回活动的锚点中?**
答案是肯定的。
## 根本原因
根本原因是**各内容节点类型的 URL 清理不一致**。
只要整体内容符合 ProseMirror schema,服务器端的内容路径就会接受任意的附件 URL。
在存在漏洞的版本中:
- `CreatePageDto` 接受 `content?: string | object`
- `PageService.parseProsemirrorContent()` 标准化 `markdown`、`html` 或 `json`
- 服务器随后调用 `jsonToNode(prosemirrorJson)`
- 如果 schema 验证通过,就会存储内容
该验证步骤检查的是结构有效性,而不是 URL 安全性。
易受攻击服务器逻辑的关键部分实际上如下:
```
prosemirrorJson = content;
jsonToNode(prosemirrorJson);
return prosemirrorJson;
```
那里没有进行任何附件 URL scheme 标准化。
随后,附件扩展直接渲染了攻击者控制的值。
存在漏洞的附件节点执行了此操作:
```
url: {
default: "",
parseHTML: (element) => element.getAttribute("data-attachment-url"),
renderHTML: (attributes) => ({
"data-attachment-url": attributes.url,
}),
},
```
然后是:
```
[
"a",
{
href: HTMLAttributes["data-attachment-url"],
class: "attachment",
target: "blank",
},
`${HTMLAttributes["data-attachment-name"]}`,
]
```
在客户端,React 节点视图再次将其包装在:
```
```
但 `getFileUrl()` 仅对以下情况进行了特殊处理:
- 绝对 `http` URL
- `/api/...`
- `/files/...`
任何其他内容都会原样返回。
因此,像这样的 payload:
```
javascript:alert(document.domain)
```
存活了下来:
- JSON 存储
- 服务器端 schema 验证
- HTML 渲染
- 客户端 URL 处理
仅此一项就足以构成存储型 XSS。
让根本原因尤为清晰的是对比参考。
Docmost 的常规链接扩展明确阻止了 `javascript:`:
- 它在 `parseHTML()` 中拒绝了 `javascript:`
- 它在 `renderHTML()` 中将 `javascript:` href 置空
所以该产品早就知道这个 scheme 是危险的。
附件节点只是未能应用相同的策略。
这就是为什么这不是“编辑器中的通用 XSS”的原因。
这是一个特定于节点的信任边界漏洞。
## 为什么这是一个安全问题,而不仅仅是缺少清理
这个漏洞不仅仅与不安全的 HTML 美观度有关。
它允许能够编辑页面的攻击者持久化一个恶意 payload,当其他用户与渲染后的附件交互时,该 payload 稍后会在 Docmost 源中执行。
这很重要,因为同源脚本可以:
- 读取受害者可以访问的数据
- 以受害者的身份发起经过身份验证的请求
- 修改受害者被允许修改的内容
- 滥用任何暴露给该会话的 DOM 或 API 表面
点击要求并不会将其降级为一个微不足道的问题。
点击是正常产品行为的一部分:
UI 有意将附件呈现为一个可操作的链接/图标。
所以安全问题不是“攻击者能在没有任何交互的情况下强制执行任意 JS 吗?”
真正的问题是:
**应用程序是否会存储攻击者控制的、携带脚本的内容,并在随后将其作为受信任的交互路径呈现给其他用户?**
在存在漏洞的版本中,确实如此。
这就是存储型 XSS。
## 为什么利用是切实可行的
漏洞利用路径非常直接:
- 任何拥有页面编辑权限的用户都可以植入 payload
- 恶意 URL 在存储过程中保持不变
- 页面正常渲染
- 查看者只需要对页面的标准访问权限
- 只需点击一次附件操作即可触发执行
这也使得高权限用户成为了现实的目标。
如果工作空间所有者、管理员或广泛受信任的编辑者查看了攻击者控制的内容,并点击了附件操作,攻击者的脚本就会在权限更高的会话上下文中运行。
这才是重要的实际要点:
攻击者的权限要求仅为**低级**。
受害者的权限级别决定了 XSS 会话具有多少价值。
## 概念验证
我在 **Docmost `v0.70.3`** 上验证了该问题。
PoC 仅使用了常规的 HTTP 请求和应用程序自身的页面 API。
流程如下:
1. 以具有页面编辑权限的用户身份登录。
2. 创建或选择一个页面。
3. 发送 `POST /api/pages/update`,带有 `format: "json"` 以及一个 `url` 为 `javascript:` payload 的附件节点。
4. 通过 `POST /api/pages/info` 请求取回页面。
5. 确认存储的 JSON 仍然包含恶意 URL。
6. 以 HTML 格式请求同一页面,确认服务器返回的锚点的 `href` 仍然是 `javascript:...`。
7. 在 UI 中,查看者点击渲染的附件操作,即在 Docmost 源中执行 payload。
最小化的恶意内容如下:
```
{
"pageId": "标签:Web安全, 协作知识库, 存储型XSS, 文档平台, 漏洞分析, 蓝队分析, 路径探测