- Console:React 19 + antd 6.6.5 前端(登录 admin/admin)+ Hono 管理 API (Nodes/Domains/Mailboxes/Emails/Webhooks/Keys/OAuth/Admin) - Node:Bun 转发节点(HMAC 签名、有限重试、心跳、结构化日志、优雅退出) - Email Worker:Cloudflare email handler + 本地联调入口 - shared:HMAC 协议(docs/04)、wmh_ Key(32 位)、AES-GCM 加密、SSRF 防护 - 邮件管线:postal-mime 解析、附件、catch-all、限额、malformed 降级、TTL 1 天 - Webhook:签名投递、指数退避重试、SSRF 校验、投递日志 - API v1 + MCP(JSON-RPC,六工具,凭据 kind 隔离) - Cloudflare OAuth:真实 + 本地 sim 模式,自动安装管线(幂等,无需手工命令) - 测试:bun test 41 例(协议安全/管线/TTL/Webhook/SSRF/MCP/OAuth/鉴权)+ Playwright UI 冒烟 - LOGO/favicon 由 codex CLI 生成
136 lines
4.7 KiB
TypeScript
136 lines
4.7 KiB
TypeScript
/**
|
|
* 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<string[]>;
|
|
|
|
/** # 函数
|
|
* 完整 SSRF 校验:静态检查 + (可选)DNS 解析结果检查
|
|
* 参数: rawUrl - endpoint;resolve - DNS 解析器(本地 Bun/Node 运行时注入,Workers 环境可省略)
|
|
* 返回值: null 表示通过;否则返回拒绝原因
|
|
*/
|
|
export async function assertWebhookUrlSafe(
|
|
rawUrl: string,
|
|
resolve?: HostResolver,
|
|
allowPrivateHosts = false,
|
|
): Promise<string | null> {
|
|
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;
|
|
}
|