Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions src/backend/pkg/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 重绑定特征
Expand Down Expand Up @@ -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
}

Expand Down
148 changes: 148 additions & 0 deletions src/backend/pkg/http_ssrf.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
Loading