Repository navigation
fix(webdav): make PROPFIND href a spec-compliant Request-URI reference - #110
Closed
wumingzhinu wants to merge 1 commit into
Closed
wumingzhinu wants to merge 1 commit into
wumingzhinu wants to merge 1 commit into
Conversation
修复 Issue OpenListTeam#104:标准 WebDAV 客户端挂载 /dav 后所有目录显示为空。 ## 规范依据(RFC 4918) §14.7 DAV:href 元素的 Purpose 是「MUST contain a URI or a relative reference」,Value 为 Simple-ref;§8.3 给出产生式: Simple-ref = absolute-URI | ( path-absolute [ "?" query ] ) 并规定:形态二选一(相对引用按 Request-URI 解析,或完整 URI), 同一个 Multi-Status 响应内所有 href 必须同形,且 MUST NOT 有 「与 Request-URI 前缀不匹配」的 href;集合标识符 SHOULD 以 '/' 结尾。 §8.3.1 的示例把两种合法形态并列为: 'http://example.com/sample/' 和 'http://example.com/sample/a%20test' '/sample/' 和 '/sample/a%20test' 本实现取**相对引用**:href 与请求路径天然一致、子路径挂载自动正确、 且不回显 Host 头。 ## 修的三个缺陷 1. href 缺挂载前缀(issue 报告的)。davPathOf() 已剥掉前缀,PROPFIND 却把 这个已剥前缀的路径直接当 href 输出,于是请求 /dav/wewe 回 /wewe/, 客户端判定路径对不上并丢弃全部记录(rclone: Item with unknown path received)。表现为「能下载但列不出目录」,因为 GET 走 302 重定向不依赖 href。 2. 自身 href 是裸输出,会产出非法 XML(审计新发现,issue 未提)。 davPathOf() 已对 pathname 做过 decodeURIComponent,而自身 href 原样塞进 <d:href>;目录名含 & 或 < 时 & 未转义、< 提前开始新标签,客户端解析整个 multistatus 失败: PROPFIND /dav/R&D → <d:href>/R&D/</d:href> 非法 XML PROPFIND /dav/a<b → <d:href>/a<b/</d:href> href 被 < 截断 §8.3.1 正好提醒过这点:「a legal URI may still contain characters that need to be escaped within XML character data, such as the ampersand character.」 子项名此前已被 encodeURIComponent,自身 href 是唯一的裸输出口。 3. 集合与文件未区分尾斜杠。旧实现对子项一律不追加 /,目录 href 不带尾斜杠, Windows 资源管理器等要求目录以 / 结尾(§8.3 的 SHOULD)。 ## 实现 - 新增 davRequestPath(c) 取 URL.pathname 作为 href 基准,与 davPathOf(仅用于 存储层寻址)职责分离。 - generateWebDavXml(requestPath, items):自身 href 补斜杠并做 XML 转义; 子项 href = 自身 href + 逐段 encodeURIComponent(name) + (目录 ? "/" : "")。 参考项目 Davflare 的 getResourceHref 采用同一口径。 - 编码分两路不可混用:自身 href 来自 URL.pathname(已百分号编码),再编码 会双重编码成 %2520,只能 XML 转义;子项名是解码后的原文,走逐段编码。 - 非法 UTF-16(孤立代理项)会让 encodeURIComponent 抛 URIError,逐字符兜底, 不因编码失败把整个 PROPFIND 打成 500。 - davPathOf 与 MOVE/COPY 的 Destination 解析改用前缀守卫(仅当 p === DAV_MOUNT 或以 DAV_MOUNT + "/" 开头才剥离),子路径挂载下不会像无条件 slice 那样截出 垃圾路径。 - internal/webdav/webdav.ts 改为直接从 ../../pkg/xml 导入,不再经 pkg/utils barrel(后者会拉入 hono / model/db)。 ## 验证 新增 src/backend/pkg/xml_webdav.test.ts,12 个用例全部通过,覆盖根目录、 子目录、集合/文件尾斜杠差异、子路径挂载(/list/dav、/openlist/dav)、 子项按段编码、自身 href 不双重编码(无 %2520)、裸 & 的 XML 转义与还原、 < 不截断标签、分隔符不编码成 %2F、孤立代理项不抛错、同一响应内 href 格式 一致、XML 标签配平。 端到端对照三个挂载场景(Issue OpenListTeam#104 真实场景 PROPFIND /dav/wewe): PROPFIND /dav/wewe 修复前: /wewe/ /wewe/WESSSDQ /wewe/70rop ... 前缀不一致 ✗ 修复后: /dav/wewe/ /dav/wewe/WESSSDQ/ /dav/wewe/70rop/ ... ✓ PROPFIND /list/dav/wewe 修复后前缀一致 ✓ PROPFIND /openlist/dav/wewe 修复后前缀一致 ✓ 运行:tsx --test src/backend/pkg/xml_webdav.test.ts (src/backend/pkg/ 目前未被任何 test:* 脚本覆盖,已在 OpenListTeam#97 中补 test:pkg) ## 未改动(刻意保持 PR 聚焦) issue 补充提到的另外两点未混入: - MOVE/COPY 的 Destination base 推导:现状对绝对路径形式可正常工作,收紧 校验可能让部分客户端直接失败,需单独评估兼容性。 - OPTIONS 声明 DAV: 1, 2 但 LOCK 返回 405:能力声明与实现不一致,Office 会 因此拒绝写入;改声明会影响现有客户端的能力协商结果,宜单独提 PR。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
wumingzhinu
force-pushed
the
fix/webdav-propfind-href-mount-prefix
branch
from
October 6, 2026 00:08
f7e8614 to
ce76594
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary / 摘要
修复 #104 —— 标准 WebDAV 客户端挂载
/dav后所有目录显示为空。href 采用 RFC 4918 允许的相对引用形态,编码与尾斜杠口径对齐参考项目 Davflare 的
getResourceHref。根因一(issue 报告的):href 缺挂载前缀,违反 RFC 4918 §8.3
RFC 4918 §8.3 要求
D:href是完整的请求 URI。davPathOf()已经把前缀剥掉:而 PROPFIND 分支把这个已剥前缀的路径直接当
href输出:客户端请求
/dav/wewe,服务端回/wewe/,路径对不上 → rclone 打印Item with unknown path received并丢弃全部条目;Windows 资源管理器同样打开后为空。GET不受影响(走 302 重定向,不依赖 href),所以表现为「能下不能列」。根因二(本次审计新发现,issue 未提及):自身 href 是裸输出,会产出非法 XML
davPathOf()已经对 pathname 做过decodeURIComponent,但被请求资源自身的 href 此前原样塞进<d:href>。目录名含&或<时直接产出非法 XML ——&未转义、<提前开始新标签,客户端解析整个multistatus失败:子项名此前已被
encodeURIComponent,自身 href 是唯一的裸输出口。根因三:集合与文件的尾斜杠没有区分
旧实现对子项一律不追加
/,目录的 href 因此不带尾斜杠(/wewe/70rop),部分客户端(含 Windows 资源管理器)要求目录 href 以/结尾。参考实现按isCollection决定,我们此前没区分。规范依据(RFC 4918)
查了 RFC 原文,而不是凭印象处理。§14.7 规定
DAV:href的 Purpose 是「MUST contain a URI or a relative reference」,Value 为
Simple-ref;§8.3 给出产生式并附加三条约束:/结尾。§8.3.1 的示例把两种合法形态并列给出:
本实现取相对引用,基准是真实请求路径(
URL.pathname)而非硬编码挂载前缀。三个理由:与 Request-URI 天然一致(满足 §8.3 的 MUST NOT)、子路径挂载自动正确、不回显 Host 头(避免 Host 注入把攻击者域名写进客户端的解析结果)。编码必须分两路,不能混用
这是实现里最容易踩错的地方:
URL.pathname,已百分号编码%20→%2520)encodeURIComponent/变成%2F,层级就没了§8.3.1 恰好印证了根因②:「a legal URI may still contain characters that need to be escaped within XML character data, such as the ampersand character.」——
&是 URI 合法字符,但放进 XML 文本必须转义。实现
davRequestPath(c)—— 取URL.pathname作为 href 基准,与davPathOf(仅用于存储层寻址)职责分离generateWebDavXml(requestPath, items)—— 自身 href 补斜杠 + XML 转义;子项 href = 自身 + 逐段编码名 + 目录补/buildDavChildHref导出以便单测encodeURIComponent,不用encodeDownloadPath:后者按 GoEncodePath保留$&+,:;=@,其中&在 XML 文本内容里必须转义,裸&会让<d:href>非法;而整条encodeURIComponent又会把/编码成%2F丢掉层级encodeURIComponent抛URIError,逐字符try/catch,不因编码失败把整个 PROPFIND 打成 500server/webdav.ts提取DAV_MOUNT常量 ——davPathOf的 slice、MOVE/COPY 的Destination剥离、PROPFIND 的前缀补全都用同一个常量。原先/dav这个字面量散在 3 处(其中 2 处是.replace(/^\/dav/, "")),挂载点一改就会漂移internal/webdav/webdav.ts改为直接从../../pkg/xml导入,不再经pkg/utilsbarrel(后者会拉入hono/model/db)Testing / 测试
新增
src/backend/pkg/xml_webdav.test.ts,11 个用例全部通过:覆盖的关键项(12 个):根目录 / 子目录、集合与文件尾斜杠差异、子路径挂载(
/list/dav、/openlist/dav)、子项按段编码、自身 href 不双重编码(断言无%2520)、裸&的 XML 转义与解析后还原、<不截断标签、分隔符不编码成%2F、孤立代理项不抛错、同一响应内 href 格式一致、XML 标签配平。另做修复前后对照,用 issue 里的真实场景(
PROPFIND /dav/wewe,Depth: 1):未能验证:本环境无法连接
github.com:443,未在真实客户端上跑过rclone lsd。issue 作者已在其实例上实测该补丁可恢复lsd与递归遍历(780 个对象 / 16.768 GiB),本 PR 在此基础上补齐了编码与尾斜杠部分,请评审时一并实测。一处走过的弯路(已在最终实现中修正)
第一版把
davPathOf的前缀剥离改成pathname.slice(DAV_MOUNT.length),并在生成 href 时用常量补回前缀。这在子路径挂载下是错的 —— 无条件截断 4 个字符:最终实现改为从 Request-URI 推导 href,并把
davPathOf与MOVE/COPY的Destination解析都改成前缀守卫(仅当p === DAV_MOUNT || p.startsWith(DAV_MOUNT + "/")才剥离)。未改动的地方(刻意保持 PR 聚焦)
issue 补充提到的另外两点没有混进本 PR:
MOVE/COPY的Destinationbase 推导(webdav.ts:189-191):现状对绝对路径形式的Destination可正常工作。收紧校验(如拒绝不带挂载前缀的目标)可能让部分客户端直接失败,需要单独评估兼容性后再动。OPTIONS声明DAV: 1, 2但LOCK返回 405(webdav.ts:115/214-217):这是能力声明与实现不一致,Office 会因此拒绝写入。改声明为DAV: 1是一行改动,但会改变对现有客户端的能力协商结果,宜单独提 PR。Checklist / 检查清单
gofmt,go fmt, orprettierwhere applicable.generateWebDavXml/buildWebDavPropfindResponse是内部函数,无外部调用方(已全仓 grep 确认);对外行为变化是把不合规的 href 改为合规 href。generateWebDavXml/buildWebDavPropfindResponse是内部函数,无外部调用方(已全仓 grep 确认);对外行为变化是把不合规的 href 改为 §8.3 合规的 href。配置与存储格式不变。AI Disclosure / AI 使用声明
Tools used / 使用工具:
Usage scope / 使用范围:
Code generation / 代码生成
Tests / 测试
Documentation / 文档
Review assistance / 审查辅助
I have reviewed and validated all AI-assisted content included in this PR.
I have ensured that all AI-assisted commits include
Co-Authored-Byattribution.I can reproduce all AI-assisted content included in this PR without any AI tools.