mirror of
https://github.com/labring/FastGPT.git
synced 2026-05-08 01:08:43 +08:00
@@ -15,4 +15,5 @@ description: 'FastGPT V4.14.14 更新说明'
|
||||
## ⚙️ 优化
|
||||
|
||||
1. 个人微信发布渠道,优化轮询策略(拉取与回复解耦),避免数据量超大时出现阻塞。
|
||||
2. 新增环境变量 `WECHAT_CHANNEL_CONCURRENCY`(默认 1000)用于控制微信渠道 poll worker 并发数,建议 ≥ online channel 峰值。
|
||||
2. 新增环境变量 `WECHAT_CHANNEL_CONCURRENCY`(默认 1000)用于控制微信渠道 poll worker 并发数,建议 ≥ online channel 峰值。
|
||||
3. 完善内网地址检测。
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string>(
|
||||
[
|
||||
'100.100.100.200', // 阿里云
|
||||
'fd00:ec2::254' // AWS IPv6
|
||||
].map((ip) => ipaddr.parse(ip).toNormalizedString().toLowerCase())
|
||||
);
|
||||
|
||||
// 云厂商元数据服务主机名(归一化:小写、去尾部点)
|
||||
const METADATA_HOSTNAMES = new Set<string>([
|
||||
'metadata.google.internal',
|
||||
'metadata',
|
||||
'metadata.tencentyun.com',
|
||||
'kubernetes.default.svc',
|
||||
'kubernetes.default',
|
||||
'kubernetes'
|
||||
]);
|
||||
|
||||
const LOCALHOST_HOSTNAMES = new Set<string>(['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<boolean> => {
|
||||
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';
|
||||
|
||||
@@ -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:",
|
||||
|
||||
Generated
+7
-4
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user