Repository navigation
fix(webdav): make PROPFIND href a spec-compliant Request-URI reference - #111
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' 本实现取相对引用,基准是真实请求路径(URL.pathname)而非硬编码挂载前缀。 理由:与 Request-URI 天然一致(满足 MUST NOT)、子路径挂载自动正确、 且不回显 Host 头(避免 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 §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 补斜杠;子项 href = 自身 + 逐段 encodeURIComponent(name) + (目录 ? "/" : "")。参考项目 Davflare 的 getResourceHref 采用同一口径。 编码分两路,不可混用: | | 来源 | 处理 | 原因 | | --- | --- | --- | --- | | 自身 href | URL.pathname,已百分号编码 | 只补编码 & 与 ' | 整条重编码会双重编码(%20 -> %2520) | | 子项 href | 解码后的原文 | 逐段 encodeURIComponent | 整条编码会把 / 变成 %2F,层级就没了 | 为什么自身 href 用百分号编码而不是 XML 实体(&):本仓库自己的 WebDAV 驱动(src/backend/drivers/webdav/util.ts 的 parseMultistatusXml)用正则取 href 文本且不做 XML 实体反转义,输出 & 会被它当成字面量。百分号编码同时满足两边: 既是合法 URI,又无需反转义,decodeURIComponent 后能还原原始路径。 实测 WHATWG URL 在 pathname 里已经编码掉 < > " ` 与空格,# ? 也不会出现, 裸 % 必然属于既有转义序列(用户输入的 % 会被编码成 %25),因此只补 & 与 ', 不做整条重新编码。 - 非法 UTF-16(孤立代理项)会让 encodeURIComponent 抛 URIError,逐字符兜底, 不因编码失败把整个 PROPFIND 打成 500。 - davPathOf 与 MOVE/COPY 的 Destination 解析改用前缀守卫(仅当 p === DAV_MOUNT || p.startsWith(DAV_MOUNT + "/") 才剥离)。不能像 slice(DAV_MOUNT.length) 那样无条件截断 —— 子路径挂载(/list/dav)下那会 截出 /t/dav/wewe 这样的垃圾路径,并违反 §8.3 的 MUST NOT。 - internal/webdav/webdav.ts 改为直接从 ../../pkg/xml 导入,不再经 pkg/utils barrel(后者会拉入 hono / model/db)。 ## 验证 新增 src/backend/pkg/xml_webdav.test.ts,14 个用例全部通过,覆盖根目录、 子目录、集合/文件尾斜杠差异、子路径挂载(/list/dav、/openlist/dav)、子项按段 编码、自身 href 不双重编码(断言无 %2520)、裸 & 与 apostrophe 的百分号编码、 输出不含任何 XML 实体、分隔符不编码成 %2F、孤立代理项不抛错、同一响应内 href 格式一致、XML 标签配平。 其中一项是互操作测试:模拟 parseMultistatusXml 的取名逻辑(正则取 href -> strip 尾斜杠 -> 取末段 -> decodeURIComponent),断言能从 /R%26D/、/a%26b/、/c%27d/ 正确还原出 R&D、a&b、c'd —— 即输出可被本仓库自己的 客户端解析器直接消费。 端到端对照三个挂载场景(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>
pikachuren
left a comment
There was a problem hiding this comment.
🙏 感谢 @wumingzhinu 提交!
🤖 AI 自动审核声明:本评审报告由 AI 自动生成,当前使用 GLM 模型进行分析。
🎯 结论
✅ Approve — 子路径挂载的根因修复正确,测试覆盖是本批 PR 中最完整的
📖 概要
修复 WebDAV PROPFIND 的 href 硬编码 /dav 导致 /list/dav、/openlist/dav 等子路径挂载下客户端拿到错误 Request-URI 的问题。
核心改动:href 以真实 URL.pathname 为基准生成,XML 生成逻辑迁入 pkg/xml.ts 并补齐编码与尾斜杠语义。
🧭 整体方案
把「挂载前缀推断」收敛到 davPathOf(仅真正匹配 /dav 或 /dav/ 时剥离),generateWebDavXml 接收真实请求路径并统一补目录尾斜杠——分层清晰,是最小且正确的改法。
📊 变更统计
4 个文件 | 功能 ⭐⭐⭐⭐⭐ | 最小改动 ⭐⭐⭐⭐⭐ | 前向兼容 ⭐⭐⭐⭐ | 方案设计 ⭐⭐⭐⭐⭐
🚨 关键问题
无重大问题。
P2(可选):
- 💡
encodeXmlSensitiveChars把&/'编码为%26/%27而非 XML 实体(&),是为兼容仓库自带parseMultistatusXml(按正则读 href、不做实体反转义)的权宜之计。对标准 WebDAV 客户端 percent-encoding 恰好也是合法 URI 形态,两边都能工作;建议在注释里标注这是对仓内解析器的依赖,未来若换成真 XML 解析器应改回实体转义。
📂 逐文件分析
src/backend/pkg/xml.ts
改动意图:承接 WebDAV XML 生成,修复 href 编码。
代码逻辑:encodeDavPathSegment 逐段 encodeURIComponent(保留 /),对孤立 UTF-16 代理对做兜底避免 URIError 导致整个 PROPFIND 500;buildDavChildHref 统一「目录补 /、文件不补」。
问题分析:逻辑正确,测试里对 %2520 双重编码、&、#、?、孤立代理项、XML 标签配平都有断言。
src/backend/server/webdav.ts
改动意图:PROPFIND 使用真实请求路径;MOVE/COPY 的 Destination 加 /dav 前缀守卫。
问题分析:davRequestPath 取 new URL(c.req.url).pathname,与路由挂载点解耦,方案合理。
其余文件(类型迁移 re-export)无重大问题。
✅ 待处理清单
- [P2]
pkg/xml.ts注释标注%26/%27是对仓内 WebDAV 解析器的兼容依赖
🎯 结论:✅ Approve — 修复正确、回归测试扎实,建议合并。
pikachuren
left a comment
There was a problem hiding this comment.
🙏 感谢 @wumingzhinu 提交!
🤖 AI 自动审核声明:本评审报告由 AI 自动生成,当前使用 GLM 模型进行分析。
🎯 结论
✅ Approve — 子路径挂载的根因修复正确,测试覆盖是本批 PR 中最完整的
📖 概要
修复 WebDAV PROPFIND 的 href 硬编码 /dav 导致 /list/dav、/openlist/dav 等子路径挂载下客户端拿到错误 Request-URI 的问题。
核心改动:href 以真实 URL.pathname 为基准生成,XML 生成逻辑迁入 pkg/xml.ts 并补齐编码与尾斜杠语义。
🧭 整体方案
把「挂载前缀推断」收敛到 davPathOf(仅真正匹配 /dav 或 /dav/ 时剥离),generateWebDavXml 接收真实请求路径并统一补目录尾斜杠——分层清晰,是最小且正确的改法。
📊 变更统计
4 个文件 | 功能 ⭐⭐⭐⭐⭐ | 最小改动 ⭐⭐⭐⭐⭐ | 前向兼容 ⭐⭐⭐⭐ | 方案设计 ⭐⭐⭐⭐⭐
🚨 关键问题
无重大问题。
P2(可选):
- 💡
encodeXmlSensitiveChars把&/'编码为%26/%27而非 XML 实体(&),是为兼容仓库自带parseMultistatusXml(按正则读 href、不做实体反转义)的权宜之计。对标准 WebDAV 客户端 percent-encoding 恰好也是合法 URI 形态,两边都能工作;建议在注释里标注这是对仓内解析器的依赖,未来若换成真 XML 解析器应改回实体转义。
📂 逐文件分析
src/backend/pkg/xml.ts
改动意图:承接 WebDAV XML 生成,修复 href 编码。
代码逻辑:encodeDavPathSegment 逐段 encodeURIComponent(保留 /),对孤立 UTF-16 代理对做兜底避免 URIError 导致整个 PROPFIND 500;buildDavChildHref 统一「目录补 /、文件不补」。
问题分析:逻辑正确,测试里对 %2520 双重编码、&、#、?、孤立代理项、XML 标签配平都有断言。
src/backend/server/webdav.ts
改动意图:PROPFIND 使用真实请求路径;MOVE/COPY 的 Destination 加 /dav 前缀守卫。
问题分析:davRequestPath 取 new URL(c.req.url).pathname,与路由挂载点解耦,方案合理。
其余文件(类型迁移 re-export)无重大问题。
✅ 待处理清单
- [P2]
pkg/xml.ts注释标注%26/%27是对仓内 WebDAV 解析器的兼容依赖
🎯 结论:✅ Approve — 修复正确、回归测试扎实,建议合并。
Summary / 摘要
Closes #104
修复 #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 文本必须处理。为什么用百分号编码而不是 XML 实体(
&):本仓库自己的 WebDAV 驱动parseMultistatusXml用正则取 href 文本且不做实体反转义,输出&会被它当成字面量名字。百分号编码同时满足两边。实测 WHATWG URL 在 pathname 里已编码掉< > " \`` 与空格,#?也不会出现,裸%必然属于既有转义序列,因此只补&与'`,不做整条重新编码。实现
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 个用例全部通过:覆盖的关键项(14 个):根目录 / 子目录、集合与文件尾斜杠差异、子路径挂载(
/list/dav、/openlist/dav)、子项按段编码、自身 href 不双重编码(断言无%2520)、裸&与 apostrophe 的百分号编码、输出不含任何 XML 实体、分隔符不编码成%2F、孤立代理项不抛错、同一响应内 href 格式一致、XML 标签配平。其中一项是互操作测试:本仓库自己的 WebDAV 驱动(
src/backend/drivers/webdav/util.ts的parseMultistatusXml)用正则取 href 文本且不做 XML 实体反转义。测试模拟它的取名逻辑(正则取 href → strip 尾斜杠 → 取末段 →decodeURIComponent),断言能从/R%26D/、/a%26b/、/c%27d/正确还原出R&D、a&b、c'd。这也是自身 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.