Skip to content

fix(webdav): make PROPFIND href a spec-compliant Request-URI reference - #111

Open
wumingzhinu wants to merge 1 commit into
OpenListTeam:mainfrom
wumingzhinu:fix/webdav-propfind-href-mount-prefix
Open

wumingzhinu wants to merge 1 commit into
OpenListTeam:mainfrom
wumingzhinu:fix/webdav-propfind-href-mount-prefix

Conversation

@wumingzhinu

Copy link
Copy Markdown
Contributor

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() 已经把前缀剥掉:

// 修复前
let p = pathname.replace(/^\/dav/, "")   // 剥掉挂载前缀

而 PROPFIND 分支把这个已剥前缀的路径直接当 href 输出:

// 修复前
const href = davPath === "/" ? "/" : davPath.endsWith("/") ? davPath : davPath + "/"
const xml = buildWebDavPropfindResponse(href, items)

客户端请求 /dav/wewe,服务端回 /wewe/,路径对不上 → rclone 打印 Item with unknown path received 并丢弃全部条目;Windows 资源管理器同样打开后为空。GET 不受影响(走 302 重定向,不依赖 href),所以表现为「能下不能列」。

根因二(本次审计新发现,issue 未提及):自身 href 是裸输出,会产出非法 XML

davPathOf() 已经对 pathname 做过 decodeURIComponent,但被请求资源自身的 href 此前原样塞进 <d:href>。目录名含 & 或 < 时直接产出非法 XML —— & 未转义、< 提前开始新标签,客户端解析整个 multistatus 失败:

PROPFIND /dav/R&D  →  <d:href>/R&D/</d:href>     ← 非法 XML
PROPFIND /dav/a<b  →  <d:href>/a<b/</d:href>      ← href 被 < 截断

子项名此前已被 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 给出产生式并附加三条约束:

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 天然一致(满足 §8.3 的 MUST NOT)、子路径挂载自动正确、不回显 Host 头(避免 Host 注入把攻击者域名写进客户端的解析结果)。

编码必须分两路,不能混用

这是实现里最容易踩错的地方:

来源 处理 原因
自身 href URL.pathname,已百分号编码 只补编码 & 与 ' 整条重编码会双重编码(%20 → %2520)
子项 href 解码后的原文 逐段 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 实体(&amp;):本仓库自己的 WebDAV 驱动 parseMultistatusXml 用正则取 href 文本且不做实体反转义,输出 &amp; 会被它当成字面量名字。百分号编码同时满足两边。实测 WHATWG URL 在 pathname 里已编码掉 < > " \`` 与空格,# ?也不会出现,裸%必然属于既有转义序列,因此只补&与'`,不做整条重新编码。

实现

  • 新增 davRequestPath(c) —— 取 URL.pathname 作为 href 基准,与 davPathOf(仅用于存储层寻址)职责分离
  • generateWebDavXml(requestPath, items) —— 自身 href 补斜杠 + XML 转义;子项 href = 自身 + 逐段编码名 + 目录补 /
  • buildDavChildHref 导出以便单测
  • 编码用逐段 encodeURIComponent,不用 encodeDownloadPath:后者按 Go EncodePath 保留 $&+,:;=@,其中 & 在 XML 文本内容里必须转义,裸 & 会让 <d:href> 非法;而整条 encodeURIComponent 又会把 / 编码成 %2F 丢掉层级
  • 非法 UTF-16 兜底 —— 孤立代理项会让 encodeURIComponent 抛 URIError,逐字符 try/catch,不因编码失败把整个 PROPFIND 打成 500
  • server/webdav.ts 提取 DAV_MOUNT 常量 —— davPathOf 的 slice、MOVE/COPY 的 Destination 剥离、PROPFIND 的前缀补全都用同一个常量。原先 /dav 这个字面量散在 3 处(其中 2 处是 .replace(/^\/dav/, "")),挂载点一改就会漂移
  • internal/webdav/webdav.ts 改为直接从 ../../pkg/xml 导入,不再经 pkg/utils barrel(后者会拉入 hono / model/db)

Testing / 测试

新增 src/backend/pkg/xml_webdav.test.ts,11 个用例全部通过:

✔ PROPFIND 根目录:href 带挂载前缀且以 / 结尾
✔ PROPFIND 子目录:自身与子项都带 /dav 前缀
✔ 目录带尾斜杠、文件不带
✔ 非 ASCII 与保留字符按段百分号编码
✔ 路径分隔符不被编码成 %2F
✔ mountPrefix 带或不带尾斜杠结果一致
✔ href 始终以请求路径为前缀(客户端匹配的前提)
✔ 自身 href 中的 & 与 < 必须编码,否则产生非法 XML
✔ 自身 href 覆盖空格 / 非 ASCII / # 等需编码字符
✔ 孤立代理项不抛错(encodeURIComponent 会抛 URIError)
✔ XML 结构完整且状态为 207 所需的 multistatus
ℹ tests 14  ℹ pass 14  ℹ fail 0

覆盖的关键项(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):

请求: PROPFIND /dav/wewe  (Depth: 1)

PROPFIND /dav/wewe
  修复前: /wewe/  /wewe/WESSSDQ  /wewe/70rop  ...          前缀不一致 ✗
  修复后: /dav/wewe/  /dav/wewe/WESSSDQ/  /dav/wewe/70rop/  ...  ✓

PROPFIND /list/dav/wewe        修复后前缀一致 ✓
PROPFIND /openlist/dav/wewe    修复后前缀一致 ✓

运行方式:src/backend/pkg/ 目前不被任何 test:* 脚本覆盖,已在 #97 中补了 test:pkg。本 PR 可先 tsx --test src/backend/pkg/xml_webdav.test.ts 直接运行;#97 合并后即随 test:all 一起跑。

未能验证:本环境无法连接 github.com:443,未在真实客户端上跑过 rclone lsd。issue 作者已在其实例上实测该补丁可恢复 lsd 与递归遍历(780 个对象 / 16.768 GiB),本 PR 在此基础上补齐了编码与尾斜杠部分,请评审时一并实测。

一处走过的弯路(已在最终实现中修正)

第一版把 davPathOf 的前缀剥离改成 pathname.slice(DAV_MOUNT.length),并在生成 href 时用常量补回前缀。这在子路径挂载下是错的 —— 无条件截断 4 个字符:

request pathname        slice(4) 结果       href 与 Request-URI
/dav/wewe               /wewe                被改写
/list/dav/wewe          /t/dav/wewe          被改写 ✗ 且违反 §8.3 的 MUST NOT
/openlist/dav/wewe      /nlist/dav/wewe      被改写 ✗

最终实现改为从 Request-URI 推导 href,并把 davPathOf 与 MOVE/COPY 的 Destination 解析都改成前缀守卫(仅当 p === DAV_MOUNT || p.startsWith(DAV_MOUNT + "/") 才剥离)。

未改动的地方(刻意保持 PR 聚焦)

issue 补充提到的另外两点没有混进本 PR:

  • MOVE/COPY 的 Destination base 推导(webdav.ts:189-191):现状对绝对路径形式的 Destination 可正常工作。收紧校验(如拒绝不带挂载前缀的目标)可能让部分客户端直接失败,需要单独评估兼容性后再动。
  • OPTIONS 声明 DAV: 1, 2 但 LOCK 返回 405(webdav.ts:115 / 214-217):这是能力声明与实现不一致,Office 会因此拒绝写入。改声明为 DAV: 1 是一行改动,但会改变对现有客户端的能力协商结果,宜单独提 PR。

Checklist / 检查清单

  • I have read CONTRIBUTING.
  • I confirm this contribution follows the repository license, contribution policy, and code of conduct.
  • I have formatted the changed code with gofmt, go fmt, or prettier where applicable.
  • I have requested review from relevant maintainers or code owners where applicable.
  • This PR has breaking changes. —— 无破坏性变更:generateWebDavXml / buildWebDavPropfindResponse 是内部函数,无外部调用方(已全仓 grep 确认);对外行为变化是把不合规的 href 改为合规 href。
  • This PR changes public API, config, storage format, or migration behavior. —— generateWebDavXml / buildWebDavPropfindResponse 是内部函数,无外部调用方(已全仓 grep 确认);对外行为变化是把不合规的 href 改为 §8.3 合规的 href。配置与存储格式不变。

AI Disclosure / AI 使用声明

  • This PR includes AI-assisted content.

Tools used / 使用工具:

  • Other (please specify) / 其他(请注明): Claude Code(Anthropic 官方 CLI,模型 Opus 4.8)

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-By attribution.

  • I can reproduce all AI-assisted content included in this PR without any AI tools.

修复 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 实体(&amp;):本仓库自己的 WebDAV
驱动(src/backend/drivers/webdav/util.ts 的 parseMultistatusXml)用正则取 href
文本且不做 XML 实体反转义,输出 &amp; 会被它当成字面量。百分号编码同时满足两边:
既是合法 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] WebDAV PROPFIND 返回的 D:href 缺少 /dav 挂载前缀,导致标准 WebDAV 客户端无法列目录

1 participant