Repository navigation
fix(security): close SSRF bypass via IPv4-mapped IPv6, fix IPv6 false positives - #97
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>
79d0e07 to
bda0c99
Compare
修复 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>
修复 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 — 数值化解析正确封死 IPv4-mapped IPv6 绕过,且消除了对合法公网 IPv6 的误判
📖 概要
原 SSRF 黑名单对 hostname 做 includes("::ffff:169.254.") 子串匹配,但 WHATWG URL 会把 IPv6 归一化为十六进制([::ffff:a9fe:a9fe]),匹配永远落空——攻击者可用 http://[::ffff:169.254.169.254]/ 直达云元数据;同一处还会把 [2606:4700:4700::1111] 这类合法地址误判为 loopback。
核心改动:parseIpv6Groups 把地址展开成 8 个 16 位分组做数值判定,IPv4-mapped/IPv4-compatible 复用 isSafeIpv4 同一套规则。
🧭 整体方案
「先归一化、再数值判定」是处理这类绕过的正确姿势;新增 test:pkg 脚本并接入 test:all,回归测试覆盖点分/十六进制映射、原生 v6 loopback/link-local/ULA、私网 IPv4、危险域名、协议、allowHosts 精确匹配。
📊 变更统计
3 个文件 | 功能 ⭐⭐⭐⭐⭐ | 最小改动 ⭐⭐⭐⭐⭐ | 前向兼容 ⭐⭐⭐⭐ | 方案设计 ⭐⭐⭐⭐⭐
🚨 关键问题
无重大问题。我额外核查了两类常见残留绕过,均已覆盖:
- 整数/十六进制 IP(
http://2130706433、0x7f000001)→ http.ts:352 按 label 整体拦截,且 WHATWG URL 对特殊协议本身也会归一化 hostname,双保险; - 前导零八进制(
0177.0.0.1)→ http.ts:319-325 拦截,注释还特别说明了避免误伤 COS 的 10 位 AppID hostname,考虑周到。
P2(可选):
- 💡 DNS 重绑定防护目前基于特征域名模式,真正的 rebind(解析结果在请求时变内网)不在本次范围内;若上游 fetch 侧能拿到解析后 IP 做二次校验会更完整,可作为后续增强。
✅ 待处理清单
- [P2] 后续考虑 fetch 侧按解析 IP 二次校验,覆盖 DNS rebind(可选)
🎯 结论:✅ Approve — 安全修复完整、测试扎实,建议合并。
#111) 修复 Issue #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 #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:* 脚本覆盖,已在 #97 中补 test:pkg) ## 未改动(刻意保持 PR 聚焦) issue 补充提到的另外两点未混入: - MOVE/COPY 的 Destination base 推导:现状对绝对路径形式可正常工作,收紧 校验可能让部分客户端直接失败,需单独评估兼容性。 - OPTIONS 声明 DAV: 1, 2 但 LOCK 返回 405:能力声明与实现不一致,Office 会 因此拒绝写入;改声明会影响现有客户端的能力协商结果,宜单独提 PR。 Co-authored-by: wumingzhinu <wumingzhinu@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.