fix(webdav): make PROPFIND href a spec-compliant Request-URI reference - #111
Open
wumingzhinu wants to merge 1 commit into
Open
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' 本实现取相对引用,基准是真实请求路径(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>
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 / 摘要
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.