Repository navigation
fix(security): close SSRF bypass via IPv4-mapped IPv6, fix IPv6 false positives - #97
Open
wumingzhinu wants to merge 1 commit into
Open
wumingzhinu wants to merge 1 commit into
wumingzhinu wants to merge 1 commit into
Conversation
… positives
isSafeUrl 的 IPv6 判定原本是对 hostname 做子串匹配:
const ipv6Patterns = ["::1", "::ffff:127.", "::ffff:10.",
"::ffff:172.", "::ffff:192.168.", "::ffff:169.254.", ...]
for (const pattern of ipv6Patterns) {
if (host.includes(pattern)) return false
}
根因:WHATWG URL 会把 IPv4-mapped IPv6 归一化成十六进制并保留方括号。
`new URL("http://[::ffff:169.254.169.254]/").hostname` 得到
`[::ffff:a9fe:a9fe]`,因此 `includes("::ffff:169.254.")` 恒为 false;
该 host 也不以字面量 "169.254.169.254" 结尾,同样躲过了 dangerousHosts。
子串匹配对归一化后的形式完全失效。
影响:以下目标修复前全部被放行,可直达云元数据 / loopback / 私网 ——
[::ffff:169.254.169.254]、[::ffff:a9fe:a9fe]、[::ffff:7f00:1]、
[::ffff:127.0.0.1]、[::ffff:127.1]、[0:0:0:0:0:ffff:127.0.0.1]、
[::ffff:a00:1]、[::ffff:c0a8:101]、[::ffff:ac10:1]、[::ffff:0:0],共 9 种。
可达性:SSRF 校验用在 raw.ts 的 safeProxyFetch(raw.ts:90 逐跳校验,
配合 raw.ts:97 的 redirect: "manual")、302 直链降级(raw.ts:177/259/573)
与 down_proxy_url(raw.ts:540)。/p /d /sd 在默认配置下无需鉴权即可驱动
整条代理链路,因此只要管理员配置了第三方实例(openlist / alist_v3 /
url_tree 等)且该实例返回恶意 raw_url,匿名用户一次请求即可让服务端去取
元数据凭据。属于防御纵深缺陷:host 来自驱动而非普通用户输入,故不构成
「任意匿名用户直连内网」,但 SSRF 黑名单作为最后一道防线在此失效。
同一处子串匹配还有反向问题:黑名单项 "::1" 会命中任何尾段含 "::1" 的
合法公网 IPv6,[2606:4700:4700::1111](即 1.1.1.1)与 [2400:cb00::1]
被误判为 loopback 而拦截,会打断真实下载链路 —— 原实现自带的可用性缺陷。
修复:改为数值分组判定,不再依赖字符串形态。
- 新增 parseIpv6Groups:展开 :: 压缩、剥离方括号、把尾部内嵌点分 IPv4 折成
两个十六进制分组,输出 8 个 16 位分组;非法输入返回 null(fail-closed)。
- 新增 isSafeIpv6Literal:基于分组判定 ::1 / :: / fe80::/10 / fc00::/7,
并对 ::ffff:a.b.c.d 与 ::a.b.c.d 取低 32 位后复用 IPv4 规则。
- 新增 isSafeIpv4:把原先内联的 IPv4 区间规则抽成函数,供 IPv4-mapped
复用,避免两处规则随时间漂移。
- isSafeUrl 中对 [ 开头的 host 走上述判定并直接返回,其余分支一字未动。
保持 fail-closed:解析失败一律视为不安全并拦截。NAT64 保留前缀
64:ff9b::/96 未整体封禁(该前缀是标准转换前缀,整体封会破坏真实部署)。
验证:新增 src/backend/pkg/http_ssrf.test.ts(7 个用例,覆盖 10 种
IPv4-mapped 写法、原生 IPv6 各区间、IPv4 私网/元数据、协议与危险域名、
合法公网 IPv6 与云厂商 hostname、allowHosts 精确匹配)全部通过。
对修复前后做 36 例对照:修复后 0 例不符预期,其中 9 例由放行转为拦截,
2 例由误拦转为放行。
另:test:all 此前不覆盖 src/backend/pkg/**,导致同目录下已有的
crypto_security.test.ts 与 cipher_algorithms.test.ts 从未进入 test:all。
补充 test:pkg 脚本并接入 test:all。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
wumingzhinu
force-pushed
the
fix/ssrf-ipv4-mapped-ipv6-bypass
branch
from
October 2, 2026 08:13
79d0e07 to
bda0c99
Compare
15 tasks done
wumingzhinu
pushed a commit
to wumingzhinu/OpenList-Worker
that referenced
this pull request
Oct 6, 2026
修复 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
added a commit
to wumingzhinu/OpenList-Worker
that referenced
this pull request
Oct 6, 2026
修复 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>
15 tasks done
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 / 摘要
修复
isSafeUrl(src/backend/pkg/http.ts)的 SSRF 绕过,并顺带修掉同一处代码自带的 IPv6 误杀。原实现的 IPv6 判定是对 hostname 做子串匹配:
根因:WHATWG
URL会把 IPv4-mapped IPv6 归一化成十六进制并保留方括号。黑名单里写的是 IPv4-mapped 的点分十进制形式,而 URL 解析器交给代码的已经是十六进制形式,两者永不相交。该 host 也不以字面量
"169.254.169.254"结尾,因此同样躲过了dangerousHosts(http.ts:193-207)。子串匹配对归一化后的形式完全失效。修复前被放行的目标(实测 9 种写法)
http://[::ffff:169.254.169.254]/http://[::ffff:a9fe:a9fe]/http://[::ffff:7f00:1]/http://[::ffff:127.0.0.1]/http://[::ffff:127.1]/http://[0:0:0:0:0:ffff:127.0.0.1]/http://[::ffff:a00:1]/http://[::ffff:c0a8:101]/http://[::ffff:ac10:1]/http://[::ffff:0:0]/可达性
SSRF 校验用于
raw.ts的以下位置:raw.ts:90—safeProxyFetch逐跳校验(配合raw.ts:97的redirect: "manual")raw.ts:177/raw.ts:259/raw.ts:573— 302 直链降级前校验raw.ts:540—down_proxy_url重定向前校验/p、/d、/sd在默认配置下无需鉴权即可驱动整条代理链路(needDownloadSign()默认为false)。因此只要管理员配置了第三方实例(openlist/alist_v3/url_tree等)且该实例返回了恶意raw_url,匿名用户一次请求即可让服务端去取元数据凭据。这属于防御纵深缺陷:
raw_url的 host 来自驱动而非普通用户输入,所以不构成「任意匿名用户直连内网」,但 SSRF 黑名单作为最后一道防线在此失效,且缺口直达云元数据。同一处代码的另一个 bug:误杀合法公网 IPv6
黑名单项
"::1"是子串匹配,会命中任何尾段含::1的合法公网地址:http://[2606:4700:4700::1111]/http://[2400:cb00::1]/这是原实现自带的可用性缺陷 —— 会直接打断真实下载链路。
修复方式
改为数值分组判定,不再依赖字符串形态:
parseIpv6Groups— 展开::压缩、剥离方括号、把尾部内嵌点分 IPv4 折成两个十六进制分组,输出 8 个 16 位分组;非法输入返回null(fail-closed)isSafeIpv6Literal— 基于分组判定::1/::/fe80::/10/fc00::/7,并对::ffff:a.b.c.d与::a.b.c.d取低 32 位后复用 IPv4 规则isSafeIpv4— 把原先内联的 IPv4 区间规则抽成函数,供 IPv4-mapped 复用,避免两处规则随时间漂移isSafeUrl中对[开头的 host 走上述判定并直接返回,其余分支一字未动64:ff9b::/96(NAT64 标准转换前缀)未被整体封禁 —— 整体封会破坏真实部署。Testing / 测试
新增
src/backend/pkg/http_ssrf.test.ts,7 个用例全部通过:另做了修复前后 36 例对照(两版函数均从仓库源码提取,仅改导出形式,用同一组输入跑):
回归覆盖包括:
169.254.169.254/127.0.0.1/127.1/10/8/172.16/12/192.168/16/0.0.0.0/100.100.100.200/2130706433/0x7f000001/0177.0.0.1/nip.io/localtest.me/metadata.google.internal,以及必须放行的bucket-1250000000.cos.ap-guangzhou.myqcloud.com(腾讯云 COS 的 10 位数字 AppID 曾被dnsRebindPatterns的注释专门规避,不能回归)。test:all此前不覆盖src/backend/pkg/**,导致同目录已有的crypto_security.test.ts与cipher_algorithms.test.ts从未进入 test:all。本 PR 补充test:pkg并接入test:all。go test ./...—— 不适用(TypeScript 项目)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, orprettierwhere applicable.I have requested review from relevant maintainers or code owners where applicable.
This PR has breaking changes. —— 无破坏性变更:原先放行的 9 类内网目标改为拦截;原先被误拦的合法公网 IPv6 恢复放行。两者都是修复方向,但若有人在依赖旧行为请在 review 时提出。
This PR changes public API, config, storage format, or migration behavior. —— 不涉及配置与存储格式。
AI Disclosure / AI 使用声明
Tools used / 使用工具:
Usage scope / 使用范围:
Code generation / 代码生成
Tests / 测试
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.