From 08acbfac4f8965c815c855adbade96d7f429f638 Mon Sep 17 00:00:00 2001 From: Archer <545436317@qq.com> Date: Wed, 22 Apr 2026 23:35:11 +0800 Subject: [PATCH] perf: SSRF check (#6805) * perf: SSRF check * doc --- .../docs/self-host/upgrading/4-14/41414.mdx | 3 +- document/data/doc-last-modified.json | 3 +- packages/service/common/system/utils.ts | 323 +++++++++--------- packages/service/package.json | 11 +- pnpm-lock.yaml | 11 +- .../cases/service/common/system/utils.test.ts | 79 +++++ 6 files changed, 250 insertions(+), 180 deletions(-) diff --git a/document/content/docs/self-host/upgrading/4-14/41414.mdx b/document/content/docs/self-host/upgrading/4-14/41414.mdx index 90169518b3..66fb75a3f6 100644 --- a/document/content/docs/self-host/upgrading/4-14/41414.mdx +++ b/document/content/docs/self-host/upgrading/4-14/41414.mdx @@ -15,4 +15,5 @@ description: 'FastGPT V4.14.14 更新说明' ## ⚙️ 优化 1. 个人微信发布渠道,优化轮询策略(拉取与回复解耦),避免数据量超大时出现阻塞。 -2. 新增环境变量 `WECHAT_CHANNEL_CONCURRENCY`(默认 1000)用于控制微信渠道 poll worker 并发数,建议 ≥ online channel 峰值。 \ No newline at end of file +2. 新增环境变量 `WECHAT_CHANNEL_CONCURRENCY`(默认 1000)用于控制微信渠道 poll worker 并发数,建议 ≥ online channel 峰值。 +3. 完善内网地址检测。 \ No newline at end of file diff --git a/document/data/doc-last-modified.json b/document/data/doc-last-modified.json index d042276cf6..cae70b60a9 100644 --- a/document/data/doc-last-modified.json +++ b/document/data/doc-last-modified.json @@ -230,6 +230,7 @@ "document/content/docs/self-host/upgrading/4-14/41412.mdx": "2026-04-21T23:04:26+08:00", "document/content/docs/self-host/upgrading/4-14/41413.en.mdx": "2026-04-21T23:04:26+08:00", "document/content/docs/self-host/upgrading/4-14/41413.mdx": "2026-04-21T23:04:26+08:00", + "document/content/docs/self-host/upgrading/4-14/41414.mdx": "2026-04-22T16:29:40+08:00", "document/content/docs/self-host/upgrading/4-14/4142.en.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/upgrading/4-14/4142.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/upgrading/4-14/4143.en.mdx": "2026-03-03T17:39:47+08:00", @@ -391,7 +392,7 @@ "document/content/docs/self-host/upgrading/upgrade-intruction.en.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/upgrading/upgrade-intruction.mdx": "2026-04-20T13:51:34+08:00", "document/content/docs/toc.en.mdx": "2026-04-21T23:04:26+08:00", - "document/content/docs/toc.mdx": "2026-04-21T23:04:26+08:00", + "document/content/docs/toc.mdx": "2026-04-22T16:29:40+08:00", "document/content/docs/use-cases/app-cases/dalle3.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/use-cases/app-cases/dalle3.mdx": "2025-07-23T21:35:03+08:00", "document/content/docs/use-cases/app-cases/english_essay_correction_bot.en.mdx": "2026-02-26T22:14:30+08:00", diff --git a/packages/service/common/system/utils.ts b/packages/service/common/system/utils.ts index 37ec03b001..9118018387 100644 --- a/packages/service/common/system/utils.ts +++ b/packages/service/common/system/utils.ts @@ -1,4 +1,5 @@ -import { isIP, isIPv6 } from 'net'; +import ipaddr from 'ipaddr.js'; +import { isIPv6 } from 'net'; import dns from 'dns/promises'; const isDevEnv = process.env.NODE_ENV === 'development'; @@ -8,188 +9,172 @@ const SERVICE_LOCAL_HOST = ? `[${process.env.HOSTNAME}]:${SERVICE_LOCAL_PORT}` : `${process.env.HOSTNAME || 'localhost'}:${SERVICE_LOCAL_PORT}`; +// 云厂商元数据服务 IP(除 169.254.0.0/16 段外的特殊地址) +// 预先归一化为 ipaddr.js 的 normalizedString 形式以便比对 +const METADATA_IPS = new Set( + [ + '100.100.100.200', // 阿里云 + 'fd00:ec2::254' // AWS IPv6 + ].map((ip) => ipaddr.parse(ip).toNormalizedString().toLowerCase()) +); + +// 云厂商元数据服务主机名(归一化:小写、去尾部点) +const METADATA_HOSTNAMES = new Set([ + 'metadata.google.internal', + 'metadata', + 'metadata.tencentyun.com', + 'kubernetes.default.svc', + 'kubernetes.default', + 'kubernetes' +]); + +const LOCALHOST_HOSTNAMES = new Set(['localhost']); + +/** + * 把 URL hostname 尝试解析成 ipaddr.js 的地址对象 + * - 处理 IPv6 方括号 + * - 处理 IPv4-mapped IPv6 (::ffff:a.b.c.d / ::ffff:xxxx:xxxx) → 解包为 IPv4 + * - 处理十进制/十六进制/八进制/短点分 IPv4 字面量 + * 非 IP 字面量返回 null + */ +const parseHostAsIP = (rawHostname: string): ipaddr.IPv4 | ipaddr.IPv6 | null => { + const host = rawHostname.replace(/^\[|\]$/g, '').replace(/\.$/, ''); + if (!host) return null; + + // ipaddr.process 会自动把 IPv4-mapped IPv6 解包为 IPv4,处理常规字面量 + if (ipaddr.isValid(host)) { + try { + return ipaddr.process(host); + } catch { + return null; + } + } + + // ipaddr.js 不支持十进制/十六进制/八进制 IPv4 短写,手动兜底 + const numeric = parseNumericIPv4(host); + if (numeric) return ipaddr.parse(numeric) as ipaddr.IPv4; + + return null; +}; + +/** + * 解析 inet_aton 兼容的 IPv4 字面量:十进制 2852039166、十六进制 0xa9fea9fe、 + * 八进制、1-4 段形式(含 dec/hex/oct 混合)。返回标准点分十进制或 null + */ +const parseNumericIPv4 = (host: string): string | null => { + const parts = host.split('.'); + if (parts.length === 0 || parts.length > 4) return null; + + const nums: number[] = []; + for (const part of parts) { + if (!part) return null; + let n: number; + if (/^0x[0-9a-f]+$/i.test(part)) n = parseInt(part, 16); + else if (/^0[0-7]+$/.test(part)) n = parseInt(part, 8); + else if (/^\d+$/.test(part)) n = parseInt(part, 10); + else return null; + if (!Number.isFinite(n) || n < 0) return null; + nums.push(n); + } + + const maxLast = [0xffffffff, 0xffffff, 0xffff, 0xff][parts.length - 1]; + if (nums[nums.length - 1] > maxLast) return null; + for (let i = 0; i < nums.length - 1; i++) if (nums[i] > 0xff) return null; + + let ipInt = 0; + for (let i = 0; i < nums.length - 1; i++) ipInt = (ipInt + nums[i]) * 256; + ipInt += nums[nums.length - 1]; + if (ipInt > 0xffffffff) return null; + + return [(ipInt >>> 24) & 0xff, (ipInt >>> 16) & 0xff, (ipInt >>> 8) & 0xff, ipInt & 0xff].join( + '.' + ); +}; + +const normalizeDomain = (rawHostname: string): string => + rawHostname + .replace(/^\[|\]$/g, '') + .replace(/\.$/, '') + .toLowerCase(); + +/** + * ipaddr.js range() 返回的所有非 'unicast' 分类都视为内部地址。 + * 主要范围:private / loopback / linkLocal / uniqueLocal / reserved / + * multicast / broadcast / unspecified / carrierGradeNat 等 + */ +const isInternalIPAddress = (addr: ipaddr.IPv4 | ipaddr.IPv6): boolean => { + return addr.range() !== 'unicast'; +}; + +/** + * 元数据端点: + * - 169.254.0.0/16 link-local 段全部视为元数据 + * - 显式列表里的 IP(阿里云 100.100.100.200、AWS IPv6 fd00:ec2::254) + */ +const isMetadataIPAddress = (addr: ipaddr.IPv4 | ipaddr.IPv6): boolean => { + if (addr.kind() === 'ipv4' && addr.range() === 'linkLocal') return true; + return METADATA_IPS.has(addr.toNormalizedString().toLowerCase()); +}; + export const isInternalAddress = async (url: string): Promise => { if (isDevEnv) return false; - const isInternalIPv6 = (ip: string): boolean => { - // 移除 IPv6 地址中的方括号(如果有) - const cleanIp = ip.replace(/^\[|\]$/g, ''); - - // 检查 IPv4-mapped IPv6 地址(格式:::ffff:xxxx:xxxx) - // Node.js URL 解析器会将 IPv4 部分转换为十六进制 - const ipv4MappedPattern = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i; - const ipv4MappedMatch = cleanIp.match(ipv4MappedPattern); - - if (ipv4MappedMatch) { - // 将十六进制转换回 IPv4 地址 - const hex1 = parseInt(ipv4MappedMatch[1], 16); - const hex2 = parseInt(ipv4MappedMatch[2], 16); - - // hex1 包含前两个字节,hex2 包含后两个字节 - const byte1 = (hex1 >> 8) & 0xff; - const byte2 = hex1 & 0xff; - const byte3 = (hex2 >> 8) & 0xff; - const byte4 = hex2 & 0xff; - - const ipv4 = `${byte1}.${byte2}.${byte3}.${byte4}`; - return isInternalIPv4(ipv4); - } - - // IPv6 内部地址范围 - const internalIPv6Patterns = [ - /^::1$/, // Loopback - /^::$/, // Unspecified - /^fe80:/i, // Link-local - /^fc00:/i, // Unique local address (ULA) - /^fd00:/i, // Unique local address (ULA) - /^::ffff:0:0/i, // IPv4-mapped IPv6 - /^::ffff:127\./i, // IPv4-mapped loopback - /^::ffff:10\./i, // IPv4-mapped private (10.0.0.0/8) - /^::ffff:172\.(1[6-9]|2[0-9]|3[0-1])\./i, // IPv4-mapped private (172.16.0.0/12) - /^::ffff:192\.168\./i, // IPv4-mapped private (192.168.0.0/16) - /^::ffff:169\.254\./i, // IPv4-mapped link-local - /^::ffff:100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\./i // IPv4-mapped shared address space - ]; - - return internalIPv6Patterns.some((pattern) => pattern.test(cleanIp)); - }; - const isInternalIPv4 = (ip: string): boolean => { - // 验证是否为有效的 IPv4 格式 - const ipv4Pattern = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; - const match = ip.match(ipv4Pattern); - - if (!match) { - return false; - } - - // 解析 IP 地址的各个部分 - const parts = [ - parseInt(match[1], 10), - parseInt(match[2], 10), - parseInt(match[3], 10), - parseInt(match[4], 10) - ]; - - // 验证每个部分是否在有效范围内 (0-255) - if (parts.some((part) => part < 0 || part > 255)) { - return false; - } - - // 检查是否为内部 IP 地址范围 - return ( - parts[0] === 0 || // 0.0.0.0/8 - Current network - parts[0] === 10 || // 10.0.0.0/8 - Private network - parts[0] === 127 || // 127.0.0.0/8 - Loopback - (parts[0] === 169 && parts[1] === 254) || // 169.254.0.0/16 - Link-local - (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || // 172.16.0.0/12 - Private network - (parts[0] === 192 && parts[1] === 168) || // 192.168.0.0/16 - Private network - (parts[0] >= 224 && parts[0] <= 239) || // 224.0.0.0/4 - Multicast - (parts[0] >= 240 && parts[0] <= 255) || // 240.0.0.0/4 - Reserved - (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127) || // 100.64.0.0/10 - Shared address space - (parts[0] === 192 && parts[1] === 0 && parts[2] === 0) || // 192.0.0.0/24 - IETF Protocol Assignments - (parts[0] === 192 && parts[1] === 0 && parts[2] === 2) || // 192.0.2.0/24 - Documentation (TEST-NET-1) - (parts[0] === 198 && parts[1] === 18) || // 198.18.0.0/15 - Benchmarking - (parts[0] === 198 && parts[1] === 19) || // 198.18.0.0/15 - Benchmarking - (parts[0] === 198 && parts[1] === 51 && parts[2] === 100) || // 198.51.100.0/24 - Documentation (TEST-NET-2) - (parts[0] === 203 && parts[1] === 0 && parts[2] === 113) // 203.0.113.0/24 - Documentation (TEST-NET-3) - ); - }; - + let parsedUrl: URL; try { - const parsedUrl = new URL(url); - // 移除 IPv6 地址的方括号(如果有) - const hostname = parsedUrl.hostname.replace(/^\[|\]$/g, ''); - const fullUrl = parsedUrl.toString(); + parsedUrl = new URL(url); + } catch { + return false; + } - // 1. 检查 localhost 和常见的本地域名变体 - const localhostVariants = ['localhost', '127.0.0.1', '::1', '0.0.0.0']; - const localHostname = SERVICE_LOCAL_HOST.split(':')[0]; + const hostDomain = normalizeDomain(parsedUrl.hostname); + const localHost = SERVICE_LOCAL_HOST.split(':')[0].toLowerCase(); - if (localhostVariants.includes(hostname) || hostname === localHostname) { - return true; - } + // 1. localhost / 本机 + if (LOCALHOST_HOSTNAMES.has(hostDomain) || hostDomain === localHost) { + return true; + } - // 2. 检查云服务商元数据端点(始终阻止,无论 CHECK_INTERNAL_IP 设置如何) - const metadataEndpoints = [ - // AWS - 'http://169.254.169.254/', - 'http://[fd00:ec2::254]/', + // 2. 云元数据主机名 + if (METADATA_HOSTNAMES.has(hostDomain)) { + return true; + } - // Azure - 'http://169.254.169.254/', + // 3. IP 字面量(含各种编码变体) + const ip = parseHostAsIP(parsedUrl.hostname); + const checkFullInternal = process.env.CHECK_INTERNAL_IP === 'true'; - // GCP - 'http://metadata.google.internal/', - 'http://metadata/', + if (ip) { + if (isMetadataIPAddress(ip)) return true; + // loopback/unspecified 等始终阻止(这些是显而易见的错误配置或攻击) + const range = ip.range(); + if (range === 'loopback' || range === 'unspecified') return true; + if (checkFullInternal) return isInternalIPAddress(ip); + return false; + } - // Alibaba Cloud - 'http://100.100.100.200/', - - // Tencent Cloud - 'http://metadata.tencentyun.com/', - - // Huawei Cloud - 'http://169.254.169.254/', - - // Oracle Cloud - 'http://169.254.169.254/', - - // DigitalOcean - 'http://169.254.169.254/', - - // Kubernetes - 'http://kubernetes.default.svc/', - 'https://kubernetes.default.svc/' + // 4. 域名:解析 DNS;元数据命中始终阻止,私有段受 CHECK_INTERNAL_IP 控制 + try { + const [v4Res, v6Res] = await Promise.allSettled([ + dns.resolve4(hostDomain), + dns.resolve6(hostDomain) + ]); + const resolvedIPs = [ + ...(v4Res.status === 'fulfilled' ? v4Res.value : []), + ...(v6Res.status === 'fulfilled' ? v6Res.value : []) ]; - if (metadataEndpoints.some((endpoint) => fullUrl.startsWith(endpoint))) { - return true; + for (const raw of resolvedIPs) { + if (!ipaddr.isValid(raw)) continue; + const addr = ipaddr.process(raw); + if (isMetadataIPAddress(addr)) return true; + const r = addr.range(); + if (r === 'loopback' || r === 'unspecified') return true; + if (checkFullInternal && isInternalIPAddress(addr)) return true; } - - // 3. 只有显式设置 CHECK_INTERNAL_IP=true 时才启用私有 IP 检查 - if (process.env.CHECK_INTERNAL_IP !== 'true') { - return false; - } - - // 4. 使用 Node.js 的 isIP 函数检测 IP 版本 - const ipVersion = isIP(hostname); - - if (ipVersion === 4) { - // IPv4 地址检查 - return isInternalIPv4(hostname); - } else if (ipVersion === 6) { - // IPv6 地址检查 - return isInternalIPv6(hostname); - } else { - // 不是 IP 地址,是域名 - 需要解析 - try { - // 解析所有 A 和 AAAA 记录 - const [ipv4Addresses, ipv6Addresses] = await Promise.allSettled([ - dns.resolve4(hostname), - dns.resolve6(hostname) - ]); - - // 检查所有解析的 IP 是否为内部地址 - const allIPs = [ - ...(ipv4Addresses.status === 'fulfilled' ? ipv4Addresses.value : []), - ...(ipv6Addresses.status === 'fulfilled' ? ipv6Addresses.value : []) - ]; - - // 如果任何一个解析的 IP 是内部地址,则拒绝 - for (const ip of allIPs) { - if (isInternalIPv4(ip) || isInternalIPv6(ip)) { - return true; - } - } - - return false; - } catch (error) { - return false; - } - } - } catch (error) { - // URL 解析失败 - 宽松策略:允许访问 + return false; + } catch { return false; } }; + export const PRIVATE_URL_TEXT = 'Request to private network not allowed'; diff --git a/packages/service/package.json b/packages/service/package.json index ae889a352d..4a52d12eaa 100644 --- a/packages/service/package.json +++ b/packages/service/package.json @@ -8,12 +8,12 @@ }, "dependencies": { "@apidevtools/json-schema-ref-parser": "^11.7.2", - "@mariozechner/pi-agent-core": "^0.67.3", - "@mariozechner/pi-ai": "^0.67.3", - "@fastgpt-sdk/sandbox-adapter": "^0.0.36", "@fastgpt-sdk/otel": "catalog:", + "@fastgpt-sdk/sandbox-adapter": "^0.0.36", "@fastgpt-sdk/storage": "catalog:", "@fastgpt/global": "workspace:*", + "@mariozechner/pi-agent-core": "^0.67.3", + "@mariozechner/pi-ai": "^0.67.3", "@maxmind/geoip2-node": "^6.3.4", "@modelcontextprotocol/sdk": "catalog:", "@node-rs/jieba": "2.0.1", @@ -37,12 +37,13 @@ "https-proxy-agent": "^7.0.6", "iconv-lite": "^0.6.3", "ioredis": "^5.6.0", + "ipaddr.js": "^2.3.0", "joplin-turndown-plugin-gfm": "^1.0.12", "json5": "catalog:", - "jsonrepair": "^3.0.0", - "jszip": "^3.10.1", "jsonpath-plus": "^10.3.0", + "jsonrepair": "^3.0.0", "jsonwebtoken": "^9.0.2", + "jszip": "^3.10.1", "lodash": "catalog:", "mammoth": "^1.11.0", "mime": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 089b8e1dee..ca983acfba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -345,6 +345,9 @@ importers: ioredis: specifier: ^5.6.0 version: 5.6.0 + ipaddr.js: + specifier: ^2.3.0 + version: 2.3.0 joplin-turndown-plugin-gfm: specifier: ^1.0.12 version: 1.0.12 @@ -8141,8 +8144,8 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} - ipaddr.js@2.2.0: - resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==} + ipaddr.js@2.3.0: + resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} engines: {node: '>= 10'} is-absolute-url@4.0.1: @@ -21079,7 +21082,7 @@ snapshots: ipaddr.js@1.9.1: {} - ipaddr.js@2.2.0: {} + ipaddr.js@2.3.0: {} is-absolute-url@4.0.1: {} @@ -22450,7 +22453,7 @@ snapshots: buffer-crc32: 1.0.0 eventemitter3: 5.0.1 fast-xml-parser: 5.4.2 - ipaddr.js: 2.2.0 + ipaddr.js: 2.3.0 lodash: 4.17.23 mime-types: 2.1.35 query-string: 7.1.3 diff --git a/test/cases/service/common/system/utils.test.ts b/test/cases/service/common/system/utils.test.ts index bf027ac146..17eb908140 100644 --- a/test/cases/service/common/system/utils.test.ts +++ b/test/cases/service/common/system/utils.test.ts @@ -450,4 +450,83 @@ describe('SSRF Protection - isInternalAddress', () => { expect(await isInternalAddress('http://dual-stack-public.example.com/')).toBe(false); }); }); + + // GHSA-jhqw-944x-xh94: 云元数据端点 SSRF 保护绕过 + describe('GHSA-jhqw-944x-xh94 元数据端点绕过防护', () => { + beforeEach(() => { + delete process.env.CHECK_INTERNAL_IP; + }); + + test('应该阻止显式端口绕过 http://169.254.169.254:80/', async () => { + expect(await isInternalAddress('http://169.254.169.254:80/latest/meta-data/')).toBe(true); + expect(await isInternalAddress('http://169.254.169.254:80/')).toBe(true); + }); + + test('应该阻止 IPv6-mapped IPv4 绕过 http://[::ffff:a9fe:a9fe]/', async () => { + expect(await isInternalAddress('http://[::ffff:a9fe:a9fe]/latest/meta-data/')).toBe(true); + expect(await isInternalAddress('http://[::ffff:169.254.169.254]/')).toBe(true); + }); + + test('应该阻止十六进制 IP 绕过 http://0xa9fea9fe/', async () => { + expect(await isInternalAddress('http://0xa9fea9fe/latest/meta-data/')).toBe(true); + expect(await isInternalAddress('http://0xA9FEA9FE/')).toBe(true); + }); + + test('应该阻止十进制 IP 绕过 http://2852039166/', async () => { + expect(await isInternalAddress('http://2852039166/latest/meta-data/')).toBe(true); + expect(await isInternalAddress('http://2852039166/')).toBe(true); + }); + + test('应该阻止尾部点绕过 http://169.254.169.254./', async () => { + expect(await isInternalAddress('http://169.254.169.254./latest/meta-data/')).toBe(true); + expect(await isInternalAddress('http://169.254.169.254./')).toBe(true); + }); + + test('应该阻止 nip.io 通配 DNS 绕过', async () => { + vi.mocked(dns.resolve4).mockResolvedValue(['169.254.169.254']); + vi.mocked(dns.resolve6).mockRejectedValue(new Error('No AAAA records')); + + expect(await isInternalAddress('http://169.254.169.254.nip.io/latest/meta-data/')).toBe(true); + }); + + test('应该阻止链路本地 /16 段内其他 IP', async () => { + expect(await isInternalAddress('http://169.254.1.1/')).toBe(true); + expect(await isInternalAddress('http://169.254.254.254:8080/')).toBe(true); + }); + + test('应该阻止 Alibaba Cloud 元数据 IP', async () => { + expect(await isInternalAddress('http://100.100.100.200:80/')).toBe(true); + }); + + test('应该阻止 AWS IPv6 元数据端点', async () => { + expect(await isInternalAddress('http://[fd00:ec2::254]/')).toBe(true); + }); + + test('应该阻止十进制/十六进制绕过阿里云元数据', async () => { + // 100.100.100.200 -> 0x64646464 -> 1684300900... 实际: 100*256^3+100*256^2+100*256+200 + const decimal = 100 * 256 ** 3 + 100 * 256 ** 2 + 100 * 256 + 200; + expect(await isInternalAddress(`http://${decimal}/`)).toBe(true); + expect(await isInternalAddress(`http://0x${decimal.toString(16)}/`)).toBe(true); + }); + + test('应该阻止元数据主机名的尾部点/大小写变体', async () => { + expect(await isInternalAddress('http://METADATA.google.internal/')).toBe(true); + expect(await isInternalAddress('http://metadata.google.internal./')).toBe(true); + expect(await isInternalAddress('http://kubernetes.default.svc./')).toBe(true); + }); + + test('域名解析到阿里云元数据 IP 时始终阻止', async () => { + vi.mocked(dns.resolve4).mockResolvedValue(['100.100.100.200']); + vi.mocked(dns.resolve6).mockRejectedValue(new Error('No AAAA records')); + + expect(await isInternalAddress('http://rebind.example.com/')).toBe(true); + }); + + test('域名解析到 AWS 元数据 IP 时始终阻止(无需 CHECK_INTERNAL_IP)', async () => { + vi.mocked(dns.resolve4).mockResolvedValue(['169.254.169.254']); + vi.mocked(dns.resolve6).mockRejectedValue(new Error('No AAAA records')); + + expect(await isInternalAddress('http://metadata-proxy.example.com/')).toBe(true); + }); + }); });