feat: WmailHub v1.0.0 —— Cloudflare Email Workers 邮件接收与转发平台(本地可用版本)
- 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 生成
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user