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` photo0 ## 攻击链 `攻击者控制的附件节点 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 包含: ```
policy.pdf
``` 那个 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 的自定义节点都是其自身的安全边界,必须将其作为一个安全边界来进行审查。
标签:Web安全, 协作知识库, 存储型XSS, 文档平台, 漏洞分析, 蓝队分析, 路径探测