Files
thzxx 4cd6e997b5
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m45s
CI / 全量测试 (Electron ABI) (push) Failing after 6m28s
CI / 产物编译验证 (push) Successful in 11m18s
feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
2026-09-08 14:30:27 +08:00

325 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* SSRF 防护共享模块(v0.6.4 P2-2
*
* 背景:此前完整的 SSRF 校验只存在于 http_request 工具内部 —— web_fetch /
* 浏览器回退完全没有校验且 requiresPermission:falseLLM 可直接抓取
* 127.0.0.1、169.254.169.254 等内网/云元数据地址,属于工具层最大的安全不对称。
*
* 本模块把校验逻辑抽为单一事实来源:
* - isPrivateIP(ip) IPv4/IPv6 私有段判定(含 ::ffff: 映射递归)
* - validateSSRF(url) 校验失败时抛错(原 http_request 契约)
* - safeValidateSSRF(url) 不抛错的便捷包装(工具内 return-style 使用)
*
* 已知限制(与 M7 审查结论一致):DNS rebinding 在 Node fetch 下无法彻底关闭
* (不可自定义 lookup/SNI),缓解措施为"解析全部 IP、任一私有即拒 + 重定向终态复检"。
*/
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
/**
* v0.8.0 P1-5: DNS lookup 可替换点 —— 生产恒为 node:dns/promises 的 lookup
* 测试通过替换 current 注入受控解析结果(ESM namespace 只读,无法直接打桩)。
*/
export const __dnsLookup: { current: typeof lookup } = { current: lookup };
/**
* 检查 IP 是否为私有/内网/回环/元数据地址
*
* 覆盖:
* - IPv4: 127.0.0.0/8 (回环)、10.0.0.0/8、192.168.0.0/16、172.16.0.0/12、
* 169.254.0.0/16 (链路本地,含云元数据 169.254.169.254)、0.0.0.0/8、
* 224.0.0.0/4 (组播)、240.0.0.0/4 (保留)、
* 100.64.0.0/10 (CGNATv0.8.2 P3-1)、198.18.0.0/15 (基准测试段,P3-1)
* - IPv6: ::1 (回环)、fe80::/10 (链路本地)、fc00::/7 (唯一本地)、::ffff: 映射的 IPv4
*/
export function isPrivateIP(ip: string): boolean {
// IPv4 直接检测
if (isIP(ip) === 4) {
const parts = ip.split('.').map(Number);
if (parts[0] === 127) return true; // 回环
if (parts[0] === 10) return true; // 内网
if (parts[0] === 192 && parts[1] === 168) return true; // 内网
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true; // 内网
if (parts[0] === 169 && parts[1] === 254) return true; // 链路本地(含云元数据)
if (parts[0] === 0) return true; // 0.0.0.0/8
if (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127) return true; // 100.64/10 CGNATv0.8.2 P3-1
if (parts[0] === 198 && (parts[1] === 18 || parts[1] === 19)) return true; // 198.18/15 基准测试段(v0.8.2 P3-1
if (parts[0] >= 224) return true; // 组播 + 保留
return false;
}
// IPv6 检测
if (isIP(ip) === 6) {
const lower = ip.toLowerCase();
if (lower === '::1') return true; // 回环
if (lower.startsWith('fe80:')) return true; // 链路本地
if (lower.startsWith('fc') || lower.startsWith('fd')) return true; // 唯一本地
// ::ffff: 映射的 IPv4 — 提取 IPv4 部分递归检测
const v4MappedMatch = lower.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (v4MappedMatch) return isPrivateIP(v4MappedMatch[1]);
return false;
}
// 非 IP 格式(域名等),由调用方 DNS 解析后再检测
return false;
}
/**
* SSRF 校验 + 公网地址解析(单一事实来源)
*
* v0.7.3 P2-1 重构:resolvePublicAddresses 承载全部校验逻辑并返回解析出的
* 公网 IP 集合;validateSSRF 成为它的"只要不抛错"薄包装。这样 pinning 层
* ssrf-dispatcher)能拿到与校验完全同一批 IP,避免"校验一次解析、连接
* 再解析一次"的双解析不一致。
*
* 1. 协议白名单:仅允许 http/https
* 2. hostname 为 IP 时直接检测
* 3. 域名 — DNS 解析后检测所有 IP;任意一个 IP 为私有即拒绝
* (防止 DNS rebinding 中只校验第一个 IP 的绕过)
*
* @returns 校验通过的全部公网 IP(供 DNS pinning 使用)
* @throws 如果 URL 指向私有/内网/回环地址或协议不被允许
*/
export async function resolvePublicAddresses(url: string): Promise<string[]> {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error(`Invalid URL: ${url}`);
}
// 协议白名单
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`Blocked SSRF: protocol "${parsed.protocol}" not allowed (only http/https)`);
}
const hostname = parsed.hostname;
// 如果 hostname 本身就是 IP,直接检测
if (isIP(hostname)) {
if (isPrivateIP(hostname)) {
throw new Error(`Blocked SSRF: ${hostname} is a private/loopback address`);
}
return [hostname];
}
// 域名 — DNS 解析后检测所有 IP
let addresses: Array<{ address: string }>;
try {
addresses = await __dnsLookup.current(hostname, { all: true });
} catch (err) {
throw new Error(
`Blocked SSRF: DNS resolution failed for ${hostname}: ${(err as Error).message}`,
);
}
if (addresses.length === 0) {
throw new Error(`Blocked SSRF: no DNS records for ${hostname}`);
}
const publicIps: string[] = [];
for (const { address } of addresses) {
if (isPrivateIP(address)) {
throw new Error(`Blocked SSRF: ${hostname} resolves to private IP ${address}`);
}
publicIps.push(address);
}
return publicIps;
}
/**
* SSRF 校验 — 解析 URL 域名并校验 IPv0.7.3 起为 resolvePublicAddresses 的
* "仅校验不取值"包装,校验逻辑单一来源在后者)
*
* @throws 如果 URL 指向私有/内网/回环地址或协议不被允许
*/
export async function validateSSRF(url: string): Promise<void> {
await resolvePublicAddresses(url);
}
/** validateSSRF 的不抛错包装:返回结构化结果供工具 execute 直接 return */
export async function safeValidateSSRF(
url: string,
): Promise<{ ok: true } | { ok: false; error: string }> {
try {
await validateSSRF(url);
return { ok: true };
} catch (err) {
return { ok: false, error: (err as Error).message };
}
}
/**
* v0.7.4 P2-9: 配置类 URLMCP server url / SearXNG url)的安全校验。
*
* 与工具执行路径(validateSSRF)的区别:MCP/SearXNG 实例由用户在设置页显式
* 配置且常部署在本机/内网(127.0.0.1、192.168.x 的本地 server 是合法用例),
* 不能一刀切拒绝私网。但渲染层可控的 URL 若允许指向云元数据/链路本地高危段,
* XSS 后可作内网探测跳板。
*
* 规则:
* - 协议仅 http/https
* - 阻止:169.254.169.254(云元数据)与 169.254/16 链路本地、0.0.0.0、
* [::](未指定)、fe80::/10(链路本地)、ff00::/8(组播)、
* ::ffff: 映射的 IPv4(递归复用 isPrivateIP 段位判定,云元数据映射也拦)、
* metadata.google.internal 等元数据主机名、224/4 组播与 240/4 保留段
* - 放行:127.0.0.1 / RFC1918 私网(本地/局域网 MCP、SearXNG 合法)
*
* v0.7.4 P2-9 修正:
* - IPv6 死代码:Node URL.hostname 对 IPv6 字面量**带方括号**返回(如 '[::1]'),
* 旧实现直接 isIP(hostname) 对带括号值恒 0 → 全部落入"域名放行"分支,
* [::ffff:169.254.169.254] 云元数据映射被放行。现先去括号再判定。
* - 域名尾点绕过:metadata.google.internal.(合法 FQDN 尾点)先剥离尾点再比对。
*
* @throws 如果 URL 指向高危目标或协议不被允许
*/
export function assertSafeConfigTarget(url: string): void {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error(`Invalid URL: ${url}`);
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`Blocked: protocol "${parsed.protocol}" not allowed (only http/https)`);
}
// 去方括号(IPv6 字面量) + 去尾点(FQDN 尾点)后统一判定
let hostname = parsed.hostname.toLowerCase();
if (hostname.startsWith('[') && hostname.endsWith(']')) {
hostname = hostname.slice(1, -1);
}
if (hostname.endsWith('.')) {
hostname = hostname.slice(0, -1);
}
// 云元数据/链路本地高危主机名(域名形式,含尾点剥离后)
const HIGH_RISK_HOSTS = ['metadata.google.internal', 'metadata.google', '169.254.169.254'];
if (HIGH_RISK_HOSTS.some((h) => hostname === h || hostname.endsWith('.' + h))) {
throw new Error(`Blocked: ${hostname} is a cloud metadata / link-local target`);
}
// IP 直连:仅阻止链路本地/组播/保留/0.0.0.0(本地回环与 RFC1918 私网放行)
const ipVersion = isIP(hostname);
if (ipVersion !== 0) {
if (ipVersion === 4) {
const parts = hostname.split('.').map(Number);
const blocked =
(parts[0] === 169 && parts[1] === 254) || // 链路本地(含云元数据)
parts[0] === 0 || // 0.0.0.0/8
parts[0] >= 224; // 组播 + 保留
if (blocked) {
throw new Error(`Blocked: ${hostname} is a link-local/multicast/reserved address`);
}
return;
}
// IPv6:阻止 ::(未指定)、fe80::/10(链路本地)、ff00::/8(组播)、
// 以及 ::ffff: 映射的 IPv4(复用 IPv4 段位判定,云元数据映射一并拦)。
// 注意:Node URL.hostname 对 IPv4-mapped 返回十六进制(::ffff:a9fe:a9fe),
// 需解析为 IPv4 再判定。
const lower = hostname;
const v4MappedMatch = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
if (v4MappedMatch) {
// 两段十六进制 → IPv4:段1<<8|段2
const a = parseInt(v4MappedMatch[1], 16);
const b = parseInt(v4MappedMatch[2], 16);
const v4 = `${a >> 8}.${a & 0xff}.${b >> 8}.${b & 0xff}`;
const parts = v4.split('.').map(Number);
const blocked = (parts[0] === 169 && parts[1] === 254) || parts[0] === 0 || parts[0] >= 224;
if (blocked) {
throw new Error(
`Blocked: ${hostname} (IPv4-mapped) is a link-local/multicast/reserved address`,
);
}
return;
}
if (lower === '::' || lower.startsWith('fe80:') || lower.startsWith('ff')) {
throw new Error(`Blocked: ${hostname} is an unspecified/link-local/multicast address`);
}
return;
}
// 域名(非 IP):允许(DNS 可能解析到内网,但用户显式配置的本地服务是合法场景)
}
/**
* v0.8.0 P1-5: 配置类 URL 的**深校验** —— 在 assertSafeConfigTarget 的静态规则
* 之上对域名做真实 DNS 解析,任一解析结果命中高危段(链路本地/云元数据、0/8、
* 组播保留、::ffff: 映射等价段)即拒绝。
*
* 与 assertSafeConfigTarget 的差异:后者对"域名放行"(不解析 DNS,本地实例
* 域名合法);本函数补上"域名解析到云元数据 IP"的绕过窗口
* (如 attacker.example 解析到 169.254.169.254)。
*
* 放行语义不变:127.0.0.1 / RFC1918 私网(本地 MCP/SearXNG 合法);DNS 解析
* 失败也放行(配置期校验为纵深手段 —— 离线配置合法,且运行时工具路径仍有
* 独立校验;失败仅 WARN 由调用方留痕)。
*
* @throws 如果 URL 解析结果指向高危目标或协议不被允许
*/
export async function assertSafeConfigTargetDeep(url: string): Promise<void> {
// 先执行静态规则(协议白名单/元数据主机名/IP 字面量段位)
assertSafeConfigTarget(url);
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return; // assertSafeConfigTarget 已抛,此处不可达(防御)
}
const rawHostname = parsed.hostname.toLowerCase();
// IPv6 字面量去方括号(与 assertSafeConfigTarget 同口径)
const hostname =
rawHostname.startsWith('[') && rawHostname.endsWith(']')
? rawHostname.slice(1, -1)
: rawHostname;
if (isIP(hostname) !== 0) return; // IP 字面量已由静态规则判定,无需解析
let addresses: Array<{ address: string }>;
try {
addresses = await __dnsLookup.current(hostname, { all: true });
} catch (err) {
// DNS 解析失败:放行(配置期纵深校验不做可用性裁决),由调用方日志留痕
throw new DeepCheckSoftFailure(
`config target DNS resolution failed for ${hostname}: ${(err as Error).message}`,
);
}
for (const { address } of addresses) {
// 复用 isPrivateIP 的完整段位表做"高危段"判定?——不行:isPrivateIP 把
// 回环/RFC1918 也判为私有,而配置路径放行它们。此处仅拦"静态规则拦不到、
// 但解析后才暴露"的高危段:链路本地(含云元数据)、0/8、组播保留。
if (isHighRiskResolvedIP(address)) {
throw new Error(
`Blocked: ${hostname} resolves to high-risk address ${address} (link-local/metadata/multicast)`,
);
}
}
}
/** 高危段判定(仅配置深校验使用):链路本地/0/8/组播保留,放行回环与 RFC1918 */
function isHighRiskResolvedIP(ip: string): boolean {
if (isIP(ip) === 4) {
const parts = ip.split('.').map(Number);
return (parts[0] === 169 && parts[1] === 254) || parts[0] === 0 || parts[0] >= 224;
}
if (isIP(ip) === 6) {
const lower = ip.toLowerCase();
if (lower.startsWith('fe80:') || lower.startsWith('ff')) return true;
if (lower === '::') return true;
const v4MappedMatch = lower.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (v4MappedMatch) return isHighRiskResolvedIP(v4MappedMatch[1]);
return false;
}
return false;
}
/**
* v0.8.0 P1-5: 深校验的"软失败"信号 —— DNS 解析失败不算配置错误(放行),
* 但调用方需要区分"校验通过"与"跳过校验"以便日志留痕。
*/
export class DeepCheckSoftFailure extends Error {
constructor(message: string) {
super(message);
this.name = 'DeepCheckSoftFailure';
}
}