一个低权限的 Docmost 用户可以向通用上传端点提供一个受害者的 attachmentId,并覆盖同一工作空间内另一页面的存储附件。
我在 Docmost(开源协作文档平台)中发现、负责任地披露并复现了一个 高严重性 的授权缺陷。
Docmost 官网将其描述为可本地部署、适合企业的 wiki,拥有 300万+ 次下载,并表示受到包括 维尔纽斯市、Bechtle、澳大利亚政府、红十字会 和 ETS Quebec 在内的团队信任。
该漏洞存在于 Docmost 也用于图表保存/更新流程的通用文件上传路径中。
我当时带着一个非常具体的问题审查了那段代码:
如果上传端点验证了对某个页面的编辑权限,但覆盖目标是由用户单独控制的 attachmentId 选择的,会发生什么?
在这种情况下,这个问题直接导致了一个真实的对象绑定失败。
Docmost 允许调用者发送:
pageId,以及attachmentId服务器确实执行了覆盖一致性检查,但守卫使用了错误的布尔逻辑。
这意味着请求可能通过授权并无论如何覆盖受害者附件。
此问题被分配为 CVE-2026-34213。
Docmost: docmost/docmost
安全公告: GHSA-89fp-2hch-j9gp
CVE: CVE-2026-34213
已修复版本: v0.71.0
---
攻击者控制的具有编辑权限的 pageId -> 攻击者控制的受害者 attachmentId -> 有缺陷的覆盖守卫将跨页面覆盖视为有效 -> 从受害者 attachmentId 重建存储路径 -> 攻击者字节替换受害者文件 -> 受害者页面继续提供修改后的附件
Docmost 将上传的页面附件存储为数据库记录以及存储中的备份文件。
对于正常上传,服务器会创建新的附件 ID 并写入新文件。
然而,对于图表保存/更新流程,客户端有意重用现有的 attachmentId,以便同一图表文件可以在原地更新,而不是每次生成全新的附件记录。
这种行为本身是合理的。
问题在于它创建了一个高风险路径:
每当一个端点混合了这两个职责时,实现必须将它们精确地绑定在一起。
Docmost 没有做到这一点。
混合创建/更新端点是授权漏洞的常见来源。
原因很简单:
这正是这里的模式。
POST /api/files/upload 验证了调用者可以编辑由 pageId 命名的页面。
但如果也提供了 attachmentId,服务器会切换到覆盖路径并单独选择一个现有的附件记录。
这就产生了关键的安全问题:
覆盖路径是否验证了所选附件确实属于被授权的页面?
易受攻击版本中的答案是否定的。
根本原因是 通过用户控制的密钥绕过授权,再加上覆盖守卫中的布尔逻辑错误。
易受攻击的流程如下:
AttachmentController.uploadFile() 从多部分表单数据中读取 pageId。validateCanEdit(page, user)。attachmentId。AttachmentService.uploadFile() 通过攻击者提供的 ID 加载现有附件。&& 而不是在有任一不匹配时拒绝。易受攻击的守卫是:
if (
existingAttachment.pageId !== pageId &&
existingAttachment.fileExt !== preparedFile.fileExtension &&
existingAttachment.workspaceId !== workspaceId
) {
throw new BadRequestException("File attachment does not match");
}
该条件仅在以下情况同时成立时才拒绝请求:
这与覆盖守卫应该做的正好相反。
对于真实的攻击场景,攻击者有意停留在同一工作空间内。
所以:
existingAttachment.workspaceId !== workspaceId 为 false一旦该操作数为 false,整个 && 条件评估为 false,即使附件属于不同的页面。
因此服务器将跨页面覆盖视为有效。
这是漏洞的前半部分。
后半部分是影响变得真实的原因。
检查之后,服务使用攻击者提供的 attachmentId 和文件名重建了目标存储路径:
const filePath =
`${getAttachmentFolderPath(AttachmentType.File, workspaceId)}/` +
`${attachmentId}/${preparedFile.fileName}`;
然后,在更新路径上,Docmost 只更新了可变元数据,例如:
fileSizeupdatedAt它没有将所有权重新绑定到攻击者的页面。
因此受害者页面继续指向相同的附件记录和相同的附件 ID。 只有底层文件字节发生了变化。
这就是为什么这不是一个无害的不匹配问题。
这是一个持久的、未经授权的覆盖原语。
这不是一个表面上的 bug,也不是文件名冲突问题。
攻击者不需要竞争条件。 攻击者不需要猜测随机路径。 攻击者不需要对受害者页面的写入权限。
他们只需要:
从那里,他们可以替换另一个页面附件的存储文件字节,而受害者页面继续引用并提供该附件,就好像什么都没变一样。
这是一个直接的完整性失败。
在实际情况下,攻击者可以:
关键点是:
服务器接受了攻击者选择的覆盖目标,而没有将其绑定到实际检查了编辑权限的页面上。
这是一个访问控制失败,而不仅仅是糟糕的布尔卫生。
对于图表附件来说,利用尤其可行。
Docmost 的客户端在图表保存时有意重用 attachmentId,并使用确定性的文件名:
diagram.excalidraw.svgdiagram.drawio.svg这很重要,因为它降低了攻击者的需求。
对于通用附件,攻击者需要同时具备:
对于图表,文件名已经是可预测的。
所以如果攻击者可以读取受害者页面内容,他们通常可以恢复他们唯一缺失的部分:
attachmentId在我的验证设置中,我正好使用了这条路径:
这已经足够。
利用跨越了同一工作空间内的页面边界和空间边界,同时仍然满足了有缺陷的工作空间检查。
我针对 Docmost v0.70.3 使用基于 docmost/docmost:0.70.3、Postgres 和 Redis 构建的一次性实验室进行了现场验证。
PoC 流程:
attachmentId。POST /api/files/upload,包含:
pageId = 攻击者页面 IDattachmentId = 受害者附件 IDfile = 使用受害者文件名的攻击者控制替换文件最小的请求格式为:
POST /api/files/upload
Content-Type: multipart/form-data
pageId=<attackerPageId>
attachmentId=<victimAttachmentId>
[email protected];filename=diagram.excalidraw.svg
观察到的现场结果:
019d18ae-b176-751c-8525-b5f3cede131d019d18ae-b15b-70e9-ac67-64948e87cc5e019d18ae-b12f-75ec-8c1c-5aff3ba6be9c200 OK686a0a0ede90ece1cbb975bb29304a6c3a90373a9c3ab2496345cf7ca59cc8fa
e0168298846cdaf75c4d880f4b721d7c0ef0ef310f75617bf2b833af34cdbeba
e0168298846cdaf75c4d880f4b721d7c0ef0ef310f75617bf2b833af34cdbeba
来自另一页面的攻击者替换内容
这是一个完整的端到端覆盖证明,而不仅仅是理论上的源代码审查。
在分类期间,我使用了两种证明风格:
独立测试工具有助于隔离布尔逻辑错误。
实时 HTTP PoC 是更有力的证据,因为它证明了完整的安全故事:
attachmentId 被接受这种区别在访问控制漏洞中很重要。
“条件错误”本身是不够的。
“条件错误,并且应用程序可以被端到端驱动到持久的未经授权覆盖”才是完整的案例。
修复在 v0.71.0 中发布,并将覆盖守卫从 && 改为 ||:
if (
existingAttachment.pageId !== pageId ||
existingAttachment.fileExt !== preparedFile.fileExtension ||
existingAttachment.workspaceId !== workspaceId
) {
throw new BadRequestException("File attachment does not match");
}
这个补丁对于报告的漏洞来说是最小、直接且正确的。
它恢复了正确的规则:
仅当现有附件与被授权的页面/工作空间/类型假设完全匹配时,才允许覆盖。
一旦守卫在任何不匹配时拒绝:
这是正确的修复方式:
只是在被授权的页面和覆盖目标之间进行了严格的绑定。
这里还有一个更广泛的设计教训:
同时服务于原地更新流程的通用上传端点应被视为高风险 API 表面。
即使当前的 bug 被修复了,更强健的长期设计是:
但对于漏洞本身,已发布的补丁干净地关闭了核心问题。
无论项目是否为自己添加了关于此修复的私有测试,以下用例对长期覆盖都很重要:
这些测试的目的不仅仅是正确性。
而是锁定授权绑定,以便未来的“友好”上传重构不会重新打开同一类漏洞。
已发布的安全公告将此问题分类为:
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L
最终得分为 7.1 / 高,这是正确的结论。
这里的重要指标是完整性。
这不是一个低级别的元数据 bug。 攻击者完全控制了写入另一个页面附件路径的替换字节,并且受害者页面在之后继续提供修改后的对象。
这正是那种值得 完整性高 的跨记录篡改。
可用性保持低也是合理的,因为损坏图表或附加文档可能使受害者内容无法使用,但主要影响仍然是未经授权的修改,而不是完全的服务中断。
我通过 GitHub 安全公告私下报告了该问题,内容包括:
该问题被维护者接受,分配了 CVE-2026-34213,并于 2026年4月14日 发布。
公开公告中列出了:
>= v0.3.0v0.71.0该历史记录也与我本地的源代码审查一致: 在我检查的易受攻击行中,最早的可标记版本中就已存在易受攻击的覆盖逻辑。
这里有趣的教训不仅仅是“使用 || 而不是 &&。”
那是症状。
更深层次的经验是:
如果一个用户控制的字段证明授权,而另一个用户控制的字段选择要更新的对象,那么这两个字段必须显式且精确地绑定在一起。
这个规则随处可见:
当系统宣称:
它就创造了一个必须用精确匹配不变量来强制执行的安全边界。
任何比这更宽松的最终都会变成用户控制的密钥漏洞。
这个问题还强化了第二个容易被低估的观点:
守卫代码中的小布尔错误可能产生第一级安全后果。
一个“看起来合理”的三子句条件足以反转覆盖路径的保护模型。
这就是为什么这些攻击面需要经过深思熟虑的审查,而不是随意的信心。
pageId 进行检查的,但覆盖目标选择使用了单独的调用者提供的 attachmentId。v0.71.0 中的修复正确地将守卫改为在有任何不匹配时拒绝。这个漏洞并非关于奇特的存储行为。
而是关于一个更新路径过于信任攻击者选择的对象标识符。
Docmost 证明了在一个页面上的编辑权限,接受了来自另一个页面的现有附件 ID,然后让一个有缺陷的覆盖检查将这种不匹配变成了成功的跨页面文件替换。
这就是它成为 CVE-2026-34213 的原因。
v0.71.0 中的补丁干净地修复了直接的问题,但更广泛的教训仍然有价值:
当授权和对象选择被分割到不同的用户控制字段时,精确绑定就是安全属性。