diff --git a/src/backend/pkg/http.ts b/src/backend/pkg/http.ts index 84303b3d..7bffa9a7 100644 --- a/src/backend/pkg/http.ts +++ b/src/backend/pkg/http.ts @@ -189,7 +189,10 @@ function isRestrictedIpv4(octets: number[]): boolean { * 必须先解析成数值再按网段判断。 */ function parseIpv6Groups(input: string): number[] | null { - let s = String(input || "").trim().replace(/^\[/, "").replace(/\]$/, "") + let s = String(input || "") + .trim() + .replace(/^\[/, "") + .replace(/\]$/, "") const zone = s.indexOf("%") if (zone >= 0) s = s.slice(0, zone) if (!s.includes(":")) return null @@ -246,7 +249,7 @@ function embeddedIpv4(high: number, low: number): number[] { * Validate that a target URL is safe against SSRF attacks: * 1. Protocol must be http: or https: * 2. Hostname/IP must not point to loopback, private RFC 1918 networks, link-local, or cloud metadata endpoints. - * + * * 2026-09-08 安全增强: * - 扩展 IPv6 检测(包括 IPv4-mapped IPv6) * - 检测 DNS 重绑定特征 @@ -334,10 +337,7 @@ export function isSafeUrl( } // 3. 检测前导零八进制绕过(0177.0.0.1 = 127.0.0.1) - if ( - /^\d{1,3}(\.\d{1,3}){1,3}$/.test(host) && - /(^|\.)0\d+/.test(host) - ) { + if (/^\d{1,3}(\.\d{1,3}){1,3}$/.test(host) && /(^|\.)0\d+/.test(host)) { return false } diff --git a/src/backend/pkg/http_ssrf.test.ts b/src/backend/pkg/http_ssrf.test.ts new file mode 100644 index 00000000..145ff2f8 --- /dev/null +++ b/src/backend/pkg/http_ssrf.test.ts @@ -0,0 +1,148 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { isSafeUrl } from "./http" + +/** + * isSafeUrl 的 SSRF 回归测试。 + * + * 背景:WHATWG URL 会把 `http://[::ffff:169.254.169.254]/` 的 hostname 归一化成 + * `[::ffff:a9fe:a9fe]`,对 hostname 做 `includes("::ffff:169.254.")` 这类子串 + * 匹配永远命中不到 —— 攻击者据此可直达云元数据 / loopback / 私网。 + * 修复(数值分组判定)已随 #92 合入主干;本文件作为回归测试锁住这些行为, + * 防止未来重构时又退化成字符串匹配。 + */ + +test("blocks IPv4-mapped IPv6 in every textual form", () => { + // 全部等价于内网 / 元数据地址,只是十六进制压缩写法不同 + const mapped = [ + "http://[::ffff:169.254.169.254]/", // 云元数据(长写法会被归一化成 a9fe:a9fe) + "http://[::ffff:a9fe:a9fe]/", // 同上,压缩写法 + "http://[::ffff:7f00:1]/", // 127.0.0.1 + "http://[::ffff:127.0.0.1]/", // 同上,点分写法 + "http://[::ffff:127.1]/", // 短写 127.1 + "http://[0:0:0:0:0:ffff:127.0.0.1]/", // 全展开写法 + "http://[::ffff:a00:1]/", // 10.0.0.1 + "http://[::ffff:c0a8:101]/", // 192.168.1.1 + "http://[::ffff:ac10:1]/", // 172.16.0.1 + "http://[::ffff:0:0]/", // 0.0.0.0 + ] + for (const url of mapped) { + assert.equal(isSafeUrl(url), false, `must block ${url}`) + } +}) + +test("blocks native IPv6 loopback, link-local and unique-local", () => { + for (const url of [ + "http://[::1]/", // loopback + "http://[::]/", // unspecified + "http://[fe80::1]/", // link-local + "http://[febf::1]/", // link-local 上界 + "http://[fc00::1]/", // unique local + "http://[fd00::1]/", // unique local + "http://[fdff::1]/", // unique local 上界 + ]) { + assert.equal(isSafeUrl(url), false, `must block ${url}`) + } +}) + +test("blocks IPv6 embedding restricted IPv4 (6to4 / NAT64) and multicast", () => { + // 6to4 (2002::/16) 与 NAT64 (64:ff9b::/96) 会把 IPv4 埋进 IPv6 分组, + // 数值判定后必须还原并复用 IPv4 规则;组播 ff00::/8 同样不可出网 + for (const url of [ + "http://[2002:7f00:1::]/", // 6to4 内嵌 127.0.0.1 + "http://[2002:a9fe:a9fe::]/", // 6to4 内嵌 169.254.169.254(元数据) + "http://[64:ff9b::7f00:1]/", // NAT64 内嵌 127.0.0.1 + "http://[64:ff9b::a9fe:a9fe]/", // NAT64 内嵌 169.254.169.254 + "http://[ff02::1]/", // 链路本地组播 + "http://[ff0e::1]/", // 全域组播 + ]) { + assert.equal(isSafeUrl(url), false, `must block ${url}`) + } + // 同形态但内嵌公网 IPv4 的不能误杀 + for (const url of [ + "http://[2002:808:808::]/", // 6to4 内嵌 8.8.8.8 + "http://[64:ff9b::808:808]/", // NAT64 内嵌 8.8.8.8 + ]) { + assert.equal(isSafeUrl(url), true, `must allow ${url}`) + } +}) + +test("blocks IPv4 private / loopback / metadata ranges", () => { + for (const url of [ + "http://127.0.0.1/", + "http://127.1/", + "http://0.0.0.0/", + "http://10.0.0.1/", + "http://172.16.0.1/", + "http://172.31.255.255/", + "http://192.168.1.1/", + "http://169.254.169.254/latest/meta-data/", + "http://100.100.100.200/", // 阿里云元数据 + "http://100.64.0.1/", // CGNAT + ]) { + assert.equal(isSafeUrl(url), false, `must block ${url}`) + } +}) + +test("blocks non-http protocols and dangerous hostnames", () => { + for (const url of [ + "file:///etc/passwd", + "gopher://127.0.0.1/", + "ftp://127.0.0.1/", + "http://localhost/", + "http://metadata.google.internal/", + "http://2130706433/", // 整数 IP + "http://0x7f000001/", // 十六进制 IP + "http://0177.0.0.1/", // 前导零 + "http://127.0.0.1.nip.io/", // rebinding 服务 + "http://localtest.me/", + ]) { + assert.equal(isSafeUrl(url), false, `must block ${url}`) + } +}) + +/** + * 回归重点:旧的子串匹配把 "::1" 当黑名单,于是任何尾段形如 ::1 / ::1111 的 + * 合法公网 IPv6 都被误杀。2606:4700:4700::1111 就是 1.1.1.1 的 IPv6, + * 2400:cb00::1 属于 ARIN 公网段。误杀会直接打断真实下载链路。 + */ +test("does not over-block legitimate public IPv6 literals", () => { + for (const url of [ + "http://[2606:4700:4700::1111]/", // Cloudflare DNS 1.1.1.1 + "http://[2400:cb00::1]/", // 公网,2400::/12 + "http://[2001:4860:4860::8888]/", // Google DNS + "http://[2a00:1450:4001:81f::200e]/", // Google + ]) { + assert.equal(isSafeUrl(url), true, `must allow ${url}`) + } +}) + +test("does not regress normal public hosts", () => { + for (const url of [ + "https://example.com/x", + "https://cdn.jsdelivr.net/npm/pkg/file.js", + "https://storage.googleapis.com/bucket/object", + // 腾讯云 COS 的 hostname 含 10 位数字 AppID,不能被当成整数 IP 拦截 + "https://bucket-1250000000.cos.ap-guangzhou.myqcloud.com/a.txt", + ]) { + assert.equal(isSafeUrl(url), true, `must allow ${url}`) + } +}) + +test("allowHosts only exempts exact host matches", () => { + const allow = ["dav.example.com", "minio.internal"] + + // 白名单内的 host 放行(hostname 不含端口,故换端口同样放行) + assert.equal(isSafeUrl("https://dav.example.com/x", allow), true) + assert.equal(isSafeUrl("https://minio.internal:9000/x", allow), true) + + // 白名单不得「传染」:未列出的危险 host 仍必须被拦 + assert.equal(isSafeUrl("http://127.0.0.1/x", allow), false) + assert.equal( + isSafeUrl("http://169.254.169.254/latest/meta-data/", allow), + false, + ) + assert.equal(isSafeUrl("http://[::ffff:7f00:1]/", allow), false) + // 子域精确匹配之外的 host 不会因父域在白名单而被放行到内网语义上 + assert.equal(isSafeUrl("https://other.example.com/x", allow), true) +})