Docmost 接受了一个包含 javascript: URL 的附件节点,将其原样存储并通过渲染后,在 Docmost 源域中将其恢复为可点击的锚点。
我识别、负责任地披露并复现了 Docmost(一个开源协作文档平台)中的一个高风险存储型 XSS 问题。
Docmost 官方站点将其定位为企业级本地部署 Wiki,拥有 3M+ 下载量,并声称被包括 维尔纽斯市政府、Bechtle、澳大利亚政府、红十字会 和 ETS Quebec 在内的组织团队所信赖。
该漏洞位于富文本系统中一个容易被忽视的地方:
不在普通的链接扩展中,而在一个用于文件附件的独立自定义节点类型中。
我当时带着一个非常具体的问题审查编辑器管道:
如果普通链接会拦截 javascript: URL,那么附件节点在到达锚点 sink 之前是否执行相同的规则?
在受影响版本中,它们没有。
Docmost 接受了页面 JSON 中的恶意附件节点,原样存储了其 url 属性,随后将该值渲染回一个可点击的 <a href="javascript:..."> 元素。
该问题被分配为 CVE-2026-34212。
Docmost: docmost/docmost
安全公告: GHSA-cf68-cff9-hq4w
CVE: CVE-2026-34212
修复版本: v0.71.0
攻击者控制的附件节点 URL -> 页面 JSON 被接受并原样存储 -> HTML/React 渲染将该 URL 转为锚点 href -> 受害者点击附件操作 -> 攻击者控制的 JavaScript 在 Docmost 源域中执行
Docmost 以兼容 ProseMirror/Tiptap 的 JSON 格式存储页面内容。
该内容模型包括用于以下内容的自定义块节点:
附件节点存储如下字段:
urlnamemimesizeattachmentId服务器以多种格式接受页面内容:
jsonmarkdownhtml并将其规范化为 ProseMirror JSON 后再存储。
这意味着任何可以携带 URL 的节点类型都属于直接信任边界的一部分。
如果这些节点类型中的某一种最终被渲染为 <a href>,那么 URL 方案处理就不是可选的。
它是安全模型的一部分。
自定义编辑器扩展是安全偏差的常见来源。
基础系统可能已经知道如何正确处理危险 URL,但每个自定义节点仍然需要在其自己的 sink 处重新应用相同的规则。
这就产生了一种可预测的审查策略:
这正是暴露此漏洞的方式。
Docmost 的普通链接扩展已经将 javascript: 视为危险。
而其附件节点却没有。
一旦你看到这种不对称性,安全问题就变得显而易见:
能否持久化一个 url 为 javascript: 的附件节点,并将其渲染回一个活动的锚点?
答案是肯定的。
根本原因是不同内容节点类型之间 URL 清理不一致。
服务器端内容路径接受任意附件 URL,只要整体内容符合 ProseMirror 模式。
在受影响版本中:
CreatePageDto 接受 content?: string | objectPageService.parseProsemirrorContent() 将 markdown、html 或 json 规范化jsonToNode(prosemirrorJson)该验证步骤检查的是结构有效性,而非 URL 安全性。
受影响服务器逻辑的关键部分实际上是这样:
prosemirrorJson = content;
jsonToNode(prosemirrorJson);
return prosemirrorJson;
这里没有进行附件 URL 方案的规范化。
随后,附件扩展直接渲染攻击者控制的值。
受影响的附件节点做了如下操作:
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 节点视图再次将其包裹:
<a href={getFileUrl(url)} target="_blank">
但 getFileUrl() 只对以下情况进行特殊处理:
http URL/api/.../files/...其他任何内容都原样返回。
因此,一个如下 payload:
javascript:alert(document.domain)
逃脱了:
仅凭这一点就足以构成存储型 XSS。
使根本原因更为清晰的是对比点。
Docmost 的普通链接扩展显式阻止了 javascript::
parseHTML() 中拒绝了 javascript:renderHTML() 中清除了 javascript: href因此,产品已经知道该方案是危险的。
附件节点只是没有应用相同的策略。
这就是为什么这不是“编辑器中的通用 XSS”。
而是一个特定于节点的信任边界缺口。
这个 bug 不仅仅是关于不安全的 HTML 美学。
它允许可以编辑页面的攻击者持久化一个恶意 payload,当其他用户与渲染后的附件交互时,该 payload 将在 Docmost 源域中执行。
这一点很重要,因为源内脚本可以:
需要点击并不会将其降级为一个无关紧要的问题。
点击是产品正常行为的一部分: UI 有意将附件呈现为可操作的链接/图标。
因此,安全问题不是“攻击者能否在不进行任何交互的情况下强制执行任意 JS?”
真正的问题是:
应用程序是否存储了攻击者控制的包含脚本的内容,并随后将其作为可信交互路径呈现给其他用户?
在受影响版本中,它确实如此。
这就是存储型 XSS。
利用路径非常简单:
这也使得高权限用户成为现实目标。
如果工作区所有者、管理员或广泛受信任的编辑器查看了攻击者控制的内容并点击了附件操作,那么攻击者的脚本将在该更高权限的会话上下文中运行。
这是重要的实际要点:
攻击者的权限要求仅仅是低的。 受害者的权限级别决定了 XSS 会话携带的价值。
我针对 Docmost v0.70.3 进行了实体验证。
PoC 仅使用正常的 HTTP 请求和应用程序自身的页面 API。
流程如下:
POST /api/pages/update,其中 format: "json",并包含一个 url 为 javascript: payload 的附件节点。POST /api/pages/info 请求该页面。href 仍然是 javascript:...。最小的恶意内容如下:
{
"pageId": "<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"
}
我测试中观察到的实际结果是:
019d18cf-4212-70b0-894a-fe20080fb0f1POST /api/pages/info 返回的存储 JSON 中包含:"url": "javascript:alert(document.domain)"
POST /api/pages/info 以 format: "html" 返回的 HTML 包含:<div data-type="attachment" data-attachment-url="javascript:alert(document.domain)" data-attachment-name="policy.pdf" data-attachment-mime="application/pdf" data-attachment-size="1"><a href="javascript:alert(document.domain)" class="attachment" target="blank">policy.pdf</a></div>
该 HTML 响应是关键证据。
我无需依赖“浏览器可能会做一些有趣的事情”这种含糊的说法。
应用程序自身渲染了确切的执行 sink。
一旦用户点击该附件链接/图标,浏览器将在创建该页面的源域中执行 javascript: URL。
对于编辑器驱动的 XSS,仅凭截图是不够的证据。
它们显示症状,而非边界失效。
这就是为什么我将 PoC 构建为两个明确的检查点:
存储证据表明服务器接受并保留了危险方案。
渲染 sink 证据表明应用程序将该存储值恢复为:
<a href="javascript:...">
这种区分很重要。
如果产品存储了危险输入但在每个 sink 之前将其中和,你可能有一个加固缺口,但不一定是活着的 XSS。
如果产品存储了危险输入并随后将其渲染为实际的执行 sink,你就拥有了完整的漏洞链。
这里正是如此。
修复在 v0.71.0 中发布,通过对附件 URL 应用 URL 清理来解决渲染的利用路径。
附件扩展现在导入并使用 sanitizeUrl,包括:
data-attachment-urldata-attachment-urlhref从概念上讲,补丁将附件节点从:
改为:
客户端助手 getFileUrl() 也已更新,未知方案不再原样通过。
在补丁版本中,回退路径返回 sanitizeUrl(src) 而不是直接返回 src。
这是修复的重要部分,因为受影响的代码有两个相互强化的问题:
href补丁移除了这两个假设。
这对于活着的 XSS 路径来说是一个好的修复,因为它使附件 URL 处理与编辑器的其他安全模型保持一致。
尽管如此,还有一个更广泛的加固教训:
此处的客户端或渲染时清理是必要的,但在页面创建/更新时从服务器端拒绝危险方案将是更强的不变量。
最安全的长期模型是:
在富内容系统中,纵深防御很重要。
为了实现长期防护,以下是最需要关注的场景:
attachment.attrs.url = "javascript:..." 的 JSON 页面更新data-attachment-url="javascript:..." 的 HTML 导入href="javascript:..."/api/files/... 和 /files/...)应继续正常工作关键点是一致性。
如果普通链接被清理,但自定义的 URL 承载节点没有,那么编辑器实际上并没有单一的 URL 安全策略。
它只有碎片,而碎片正是 XSS 漏洞的栖息地。
已发布的安全公告将本问题分类为:
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:N
最终评分为 7.6 / 高。
这是一个合理的分类。
重要的属性是:
用户交互仍然是必需的,因为受害者需要激活附件链接/图标。
这就是为什么 UI:R 是正确的。
但一旦发生这种交互,安全边界已经在更早的地方被突破: 应用程序存储了一个危险方案并将其渲染回执行 sink。
我通过 GitHub 安全公告私下报告了该问题,内容包括:
该问题已被接受,分配了 CVE-2026-34212,并于 2026 年 4 月 14 日 发布。
公开公告目前列出:
0.70.30.71.0我的实体验证是在 v0.70.3 上进行的,与已发布的受影响版本一致。
主要的教训不仅仅是“清理 URL”。
每个人都知道这一点。
更有趣的教训是:
如果一个应用程序有一种安全的 URL 承载节点类型和一种不安全的 URL 承载节点类型,那么不安全的那个才是真正的策略。
富文本系统往往积累自定义扩展的速度快于积累安全审查的速度。
这正好造成了这种不对称性:
这个漏洞也表明仅模式验证是不够的。
jsonToNode() 验证了内容是结构有效的 ProseMirror 数据。
但它没有证明内容可以安全渲染。
这是两个不同的问题。
当你将这些问题分开考虑时,安全审查会变得更加精准:
附件节点通过了第一个问题,但在第三个问题上失败了。
这就是存储型内容漏洞如何在其他方面结构良好的编辑器管道中存活下来的方式。
data-attachment-url 和锚点 href。getFileUrl() 未更改地返回未知方案。javascript:,但附件节点没有。v0.71.0 中的修复为附件节点和客户端回退路径添加了 sanitizeUrl 处理。这个漏洞并非关于浏览器怪癖。
而是关于一个绕过应用程序自身 URL 安全假设的自定义内容节点。
Docmost 接受了攻击者控制的附件 URL,通过存储原样保留,然后将其渲染回应用程序源域中的一个活动锚点。
这就是它成为 CVE-2026-34212 的原因。
v0.71.0 中的补丁干净地关闭了活跃的 XSS 路径,但更广泛的教训值得铭记:
在编辑器密集型应用程序中,每个能够携带 URL 的自定义节点都是其自己的安全边界,并且必须像边界一样进行审查。