/** * Webhook endpoint SSRF 防护(docs/07、docs/08) * * 至少阻止:localhost、private IPv4/IPv6、link-local、云 metadata endpoint、 * 非法 scheme、以及 DNS 解析出私网 IP 的情况(DNS rebinding 的一半防御; * 另一半由"解析后连接同一 IP"的策略约束,本地运行时直接按解析结果拦截)。 */ /** # 成员变量: 允许的 URL scheme **/ const ALLOWED_SCHEMES = new Set(["http", "https"]); /** # 函数 * 判断 IPv4 是否为私网/保留地址 */ function isPrivateIPv4(ip: string): boolean { const parts = ip.split(".").map(Number); if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return true; const [a, b] = parts; if (a === 0 || a === 10 || a === 127) return true; // 本网段 / 私网 / loopback if (a === 169 && b === 254) return true; // link-local + 云 metadata 169.254.169.254 if (a === 172 && b >= 16 && b <= 31) return true; if (a === 192 && b === 168) return true; if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT if (a === 192 && b === 0) return true; // 192.0.0.x / 192.0.2.x if (a === 198 && (b === 18 || b === 19)) return true; // benchmark if (a >= 224) return true; // 组播 + 保留 return false; } /** # 函数 * 判断 IPv6 是否为 loopback/私网/link-local/IPv4 映射私网 */ function isPrivateIPv6(raw: string): boolean { const ip = raw.toLowerCase().replace(/^\[|\]$/g, ""); if (ip === "::" || ip === "::1") return true; if (ip.startsWith("fe8") || ip.startsWith("fe9") || ip.startsWith("fea") || ip.startsWith("feb")) { return true; // link-local fe80::/10 } if (ip.startsWith("fc") || ip.startsWith("fd")) return true; // ULA fc00::/7 if (ip.startsWith("::ffff:")) { const v4 = ip.slice(7); if (v4.includes(".")) return isPrivateIPv4(v4); } if (ip.startsWith("64:ff9b:")) return true; // NAT64 if (ip.startsWith("2002:")) { // 6to4:内嵌 IPv4 const seg = ip.slice(5); const v4 = seg .split(":")[0] .match(/.{2}/g) ?.map((x) => Number.parseInt(x, 16)) .join("."); if (v4) return isPrivateIPv4(v4); } return false; } /** # 函数 * 判断字符串是否为 IP 字面量(v4 或 v6) */ export function isIpLiteral(host: string): boolean { const h = host.replace(/^\[|\]$/g, ""); if (/^\d{1,3}(\.\d{1,3}){3}$/.test(h)) return true; if (h.includes(":")) return true; // 含冒号即视为 IPv6 return false; } /** # 函数 * 校验 IP 是否允许外发 */ export function isPublicIp(ip: string): boolean { if (ip.includes(".") && !ip.startsWith("::ffff:") && !ip.includes(":")) return !isPrivateIPv4(ip); return !isPrivateIPv6(ip); } /** # 函数 * 静态校验 webhook URL(不含 DNS 解析) * 参数: rawUrl - 用户配置的 endpoint * 返回值: null 表示通过;否则返回拒绝原因 */ export function checkWebhookUrl(rawUrl: string, allowPrivateHosts = false): string | null { let u: URL; try { u = new URL(rawUrl); } catch { return "invalid url"; } if (!ALLOWED_SCHEMES.has(u.protocol.slice(0, -1))) return "scheme not allowed"; if (allowPrivateHosts) return null; // 本地联调开关(WMAILHUB_ALLOW_PRIVATE_WEBHOOKS=1) const host = u.hostname.toLowerCase(); if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal")) { return "localhost-like host not allowed"; } if (host === "metadata.google.internal" || host === "metadata" || host.endsWith(".internal")) { return "metadata/internal host not allowed"; } if (isIpLiteral(host) && !isPublicIp(host)) { return "private/link-local IP not allowed"; } return null; } /** # 成员变量: DNS 解析函数类型(返回该主机名解析到的全部 IP) **/ export type HostResolver = (host: string) => Promise; /** # 函数 * 完整 SSRF 校验:静态检查 + (可选)DNS 解析结果检查 * 参数: rawUrl - endpoint;resolve - DNS 解析器(本地 Bun/Node 运行时注入,Workers 环境可省略) * 返回值: null 表示通过;否则返回拒绝原因 */ export async function assertWebhookUrlSafe( rawUrl: string, resolve?: HostResolver, allowPrivateHosts = false, ): Promise { const staticError = checkWebhookUrl(rawUrl, allowPrivateHosts); if (staticError) return staticError; if (allowPrivateHosts) return null; if (resolve) { const host = new URL(rawUrl).hostname; if (!isIpLiteral(host)) { let ips: string[]; try { ips = await resolve(host); } catch { return "dns lookup failed"; } if (ips.length === 0) return "dns lookup failed"; for (const ip of ips) { if (!isPublicIp(ip)) return `resolves to private address (${ip})`; } } } return null; }