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:
BigCode
2026-09-30 03:34:37 +08:00
commit d4c1334143
87 changed files with 9268 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
/** WmailHub 全局常量:邮件大小限制、TTL、Webhook 策略、签名头名称 */
/** # 成员变量: 默认邮件保留时长(秒),1 天 **/
export const DEFAULT_RETENTION_SECONDS = 86_400;
/** # 成员变量: 单封邮件最大字节数(raw MIME),Cloudflare Email Worker 上限为 25MB,取保守值 20MB **/
export const MAX_EMAIL_BYTES = 20 * 1024 * 1024;
/** # 成员变量: 单封邮件最大附件数 **/
export const MAX_ATTACHMENTS = 20;
/** # 成员变量: 单个附件最大字节数 **/
export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
/** # 成员变量: MIME 头最大字节数 **/
export const MAX_MIME_HEADER_BYTES = 256 * 1024;
/** # 成员变量: Webhook 单次投递超时(毫秒) **/
export const WEBHOOK_TIMEOUT_MS = 10_000;
/** # 成员变量: Webhook 最大尝试次数 **/
export const WEBHOOK_MAX_ATTEMPTS = 5;
/** # 成员变量: Webhook 首次重试退避(毫秒),之后指数递增 **/
export const WEBHOOK_RETRY_BASE_MS = 60_000;
/** # 成员变量: Webhook 签名头 **/
export const WEBHOOK_HEADERS = {
event: "X-WmailHub-Event",
delivery: "X-WmailHub-Delivery",
timestamp: "X-WmailHub-Timestamp",
signature: "X-WmailHub-Signature",
} as const;
/** # 成员变量: Worker -> Node ingress 共享密钥请求头 **/
export const INGRESS_SECRET_HEADER = "X-WmailHub-Ingress-Secret";
/** # 成员变量: Node 心跳间隔(毫秒) **/
export const NODE_HEARTBEAT_INTERVAL_MS = 60_000;
+149
View File
@@ -0,0 +1,149 @@
/**
* WmailHub 共享工具:哈希 / HMAC / 密码哈希 / AES-GCM 加密
* 全部基于 WebCrypto,兼容 Cloudflare Workers 与 Bun 本地运行时。
*/
const encoder = new TextEncoder();
/** # 函数
* 计算 SHA-256,返回 hex 字符串
* 参数: data - 字符串或二进制数据
* 返回值: 64 位 hex 小写
*/
export async function sha256Hex(data: string | Uint8Array | ArrayBuffer): Promise<string> {
const buf = typeof data === "string" ? encoder.encode(data) : data;
const digest = await crypto.subtle.digest("SHA-256", buf as ArrayBuffer);
return toHex(new Uint8Array(digest));
}
/** # 函数
* 计算 HMAC-SHA256,返回 hex 字符串
* 参数: secret - 密钥(字符串);message - 被签名内容
* 返回值: hex 小写签名
*/
export async function hmacSha256Hex(secret: string, message: string): Promise<string> {
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(message));
return toHex(new Uint8Array(sig));
}
/** # 函数
* PBKDF2-SHA256 派生密钥(用于用户密码哈希)
* 参数: password - 明文密码;salt - 盐(hex);iterations - 迭代次数
* 返回值: 派生密钥 hex
*/
async function pbkdf2(password: string, salt: string, iterations: number): Promise<string> {
const baseKey = await crypto.subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, [
"deriveBits",
]);
const bits = await crypto.subtle.deriveBits(
{ name: "PBKDF2", hash: "SHA-256", salt: hexToBytes(salt), iterations },
baseKey,
256,
);
return toHex(new Uint8Array(bits));
}
/** # 函数
* 生成用户密码哈希,格式 pbkdf2$<iterations>$<saltHex>$<hashHex>
* 参数: password - 明文密码
* 返回值: 可存库的哈希串
*/
export async function hashPassword(password: string): Promise<string> {
const iterations = 100_000;
const salt = toHex(crypto.getRandomValues(new Uint8Array(16)));
const hash = await pbkdf2(password, salt, iterations);
return `pbkdf2$${iterations}$${salt}$${hash}`;
}
/** # 函数
* 校验用户密码(常数时间比较)
* 参数: password - 待校验明文;stored - 库中哈希串
* 返回值: 是否匹配
*/
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
const parts = stored.split("$");
if (parts.length !== 4 || parts[0] !== "pbkdf2") return false;
const iterations = Number(parts[1]);
if (!Number.isInteger(iterations) || iterations <= 0) return false;
const hash = await pbkdf2(password, parts[2], iterations);
return timingSafeEqualHex(hash, parts[3]);
}
/** # 函数
* 由应用主密钥派生 AES-256 密钥(用于 OAuth token 等加密存储)
* 参数: masterSecret - 环境提供的主密钥字符串
* 返回值: 可用于 crypto.subtle 的 AES-GCM CryptoKey
*/
export async function deriveAesKey(masterSecret: string): Promise<CryptoKey> {
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(`wmailhub:${masterSecret}`));
return crypto.subtle.importKey("raw", digest, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
}
/** # 函数
* AES-256-GCM 加密,输出 base64(iv || ciphertext)
* 参数: key - deriveAesKey 的产物;plaintext - 明文
* 返回值: base64 密文
*/
export async function aesEncrypt(key: CryptoKey, plaintext: string): Promise<string> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoder.encode(plaintext));
const merged = new Uint8Array(iv.length + ct.byteLength);
merged.set(iv);
merged.set(new Uint8Array(ct), iv.length);
return bytesToBase64(merged);
}
/** # 函数
* AES-256-GCM 解密(aesEncrypt 的逆操作)
* 参数: key - deriveAesKey 的产物;blob - base64 密文
* 返回值: 明文;损坏时抛错
*/
export async function aesDecrypt(key: CryptoKey, blob: string): Promise<string> {
const merged = base64ToBytes(blob);
const iv = merged.slice(0, 12);
const ct = merged.slice(12);
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
return new TextDecoder().decode(pt);
}
/** # 函数
* 常数时间 hex 字符串比较,防止时序侧信道
*/
export function timingSafeEqualHex(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
export function toHex(bytes: Uint8Array): string {
let out = "";
for (const b of bytes) out += b.toString(16).padStart(2, "0");
return out;
}
export function hexToBytes(hex: string): Uint8Array<ArrayBuffer> {
const out = new Uint8Array(new ArrayBuffer(hex.length / 2));
for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
return out;
}
export function bytesToBase64(bytes: Uint8Array): string {
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin);
}
export function base64ToBytes(b64: string): Uint8Array {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
+70
View File
@@ -0,0 +1,70 @@
/**
* 部署到 Cloudflare 的 Email Worker 脚本模板。
*
* Console 在自动安装 Node 时,通过 Cloudflare API 把该脚本上传为
* Email Worker,并用占位符替换注入 Node ingress 地址与 ingress secret。
* worker/(本地开发版)与该模板保持同一转发逻辑。
*/
export const EMAIL_WORKER_SCRIPT = String.raw`// WmailHub Email Worker (auto-generated by Console provisioning)
// Cloudflare Email Routing -> Email Worker -> Node ingress
const INGRESS_URL = "__NODE_INGRESS_URL__";
const INGRESS_SECRET = "__NODE_INGRESS_SECRET__";
export default {
async email(message, env, ctx) {
const raw = await new Response(message.raw).arrayBuffer();
await forward(raw, message.from, message.to);
},
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/__health") {
return Response.json({ ok: true, service: "wmailhub-email-worker" });
}
// 本地/联调测试入口:与 email handler 走同一条转发链路
if (request.method === "POST" && url.pathname === "/__email_test") {
if (request.headers.get("X-WmailHub-Ingress-Secret") !== INGRESS_SECRET) {
return Response.json({ error: "forbidden" }, { status: 403 });
}
const from = request.headers.get("X-WmailHub-Test-From") || "sender@example.org";
const to = request.headers.get("X-WmailHub-Test-To") || "hello@example.com";
const raw = await request.arrayBuffer();
try {
await forward(raw, from, to);
return Response.json({ ok: true });
} catch (err) {
return Response.json({ error: String(err) }, { status: 502 });
}
}
return Response.json({ error: "not found" }, { status: 404 });
},
};
async function forward(raw, from, to) {
const resp = await fetch(INGRESS_URL, {
method: "POST",
headers: {
"Content-Type": "message/rfc822",
"X-WmailHub-Ingress-Secret": INGRESS_SECRET,
"X-WmailHub-Mail-From": from,
"X-WmailHub-Rcpt-To": to,
},
body: raw,
});
if (!resp.ok) {
throw new Error("node ingress responded " + resp.status);
}
}
`;
/** # 函数
* 用实际 Node ingress 配置渲染 Email Worker 脚本
* 参数: ingressUrl - Node 的 Email Worker ingress 完整 URL;ingressSecret - Worker -> Node 共享密钥
* 返回值: 可直接上传 Cloudflare 的脚本字符串
*/
export function renderEmailWorkerScript(ingressUrl: string, ingressSecret: string): string {
return EMAIL_WORKER_SCRIPT.replace("__NODE_INGRESS_URL__", ingressUrl).replace(
"__NODE_INGRESS_SECRET__",
ingressSecret,
);
}
+37
View File
@@ -0,0 +1,37 @@
/**
* WmailHub ID 生成:全部带业务前缀,便于日志与 API 识别。
*/
/** # 函数
* 生成 16 位 hex 随机串
*/
function randomHex(bytes: number): string {
const buf = crypto.getRandomValues(new Uint8Array(bytes));
let out = "";
for (const b of buf) out += b.toString(16).padStart(2, "0");
return out;
}
/** # 函数
* 生成带前缀的业务 ID,例如 node_a1b2c3d4e5f60718
* 参数: prefix - 业务前缀(node/dom/mbx/msg/att/whk/dlv/oauth/key/state/sim)
*/
export function newId(prefix: string): string {
return `${prefix}_${randomHex(8)}`;
}
export const ids = {
user: () => newId("usr"),
node: () => newId("node"),
domain: () => newId("dom"),
mailbox: () => newId("mbx"),
email: () => newId("msg"),
attachment: () => newId("att"),
webhook: () => newId("whk"),
delivery: () => newId("dlv"),
oauth: () => newId("oauth"),
apiKey: () => newId("key"),
oauthState: () => newId("st"),
credential: () => newId("cred"),
simResource: () => newId("sim"),
};
+8
View File
@@ -0,0 +1,8 @@
/** WmailHub 共享库出口:Console / Node / Email Worker / 测试共用 */
export * from "./crypto.ts";
export * from "./protocol.ts";
export * from "./keys.ts";
export * from "./ids.ts";
export * from "./ssrf.ts";
export * from "./email-worker-template.ts";
export * from "./constants.ts";
+54
View File
@@ -0,0 +1,54 @@
/**
* WmailHub Console API / MCP Key 生成与校验
*
* 规则:Key 以 `wmh_` 开头,后接随机字符串,总长度 32 位
* (即 `wmh_` + 28 位随机 [A-Za-z0-9])。
*/
/** # 成员变量: Key 前缀 **/
export const KEY_PREFIX = "wmh_";
/** # 成员变量: Key 总长度(含前缀) **/
export const KEY_TOTAL_LENGTH = 32;
/** # 成员变量: Key 随机部分字母表 **/
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
/** # 函数
* 生成随机 token(拒绝采样避免模偏差)
* 参数: length - 随机串长度
* 返回值: 随机字符串
*/
export function randomToken(length: number): string {
let out = "";
while (out.length < length) {
const bytes = crypto.getRandomValues(new Uint8Array(length * 2));
for (const b of bytes) {
if (b >= 248) continue; // 248 = 62 * 4,超出可整除区间则丢弃重采样
out += ALPHABET[b % ALPHABET.length];
if (out.length === length) break;
}
}
return out;
}
/** # 函数
* 生成 Console API / MCP Key(wmh_ 前缀,总长 32)
* 返回值: 形如 wmh_XXXXXXXXXXXXXXXXXXXXXXXXXXXX 的明文 Key(仅在创建时展示一次)
*/
export function generateWmailKey(): string {
return KEY_PREFIX + randomToken(KEY_TOTAL_LENGTH - KEY_PREFIX.length);
}
/** # 函数
* 校验 Key 格式是否符合 wmh_ + 28 位随机串、总长 32 的规范
*/
export function isValidWmailKey(key: string): boolean {
if (typeof key !== "string") return false;
if (key.length !== KEY_TOTAL_LENGTH) return false;
if (!key.startsWith(KEY_PREFIX)) return false;
for (const ch of key.slice(KEY_PREFIX.length)) {
if (!ALPHABET.includes(ch)) return false;
}
return true;
}
+169
View File
@@ -0,0 +1,169 @@
/**
* WmailHub Node <-> Console 通信协议(协议版本 1)
*
* 签名规则(docs/04_NODE_PROTOCOL.md):
* body_hash = SHA256(raw_body)
* canonical = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + body_hash
* signature = HMAC-SHA256(credential_secret, canonical)
*
* Console 端拒绝:timestamp 超窗(默认 5 分钟)、重复 nonce、错误 signature、
* 错误 body hash、已撤销 credential。
*/
import { hmacSha256Hex, sha256Hex, timingSafeEqualHex } from "./crypto.ts";
/** # 成员变量: 当前协议版本号,随请求头 X-WmailHub-Protocol 传递 **/
export const PROTOCOL_VERSION = "1";
/** # 成员变量: timestamp 允许窗口(秒),默认 5 分钟 **/
export const TIMESTAMP_WINDOW_SECONDS = 300;
/** # 函数
* 计算请求体的 SHA-256 hex(body hash)
*/
export async function bodyHash(body: Uint8Array | string): Promise<string> {
return sha256Hex(body);
}
/** # 成员变量: 签名参与要素 **/
export interface SignatureComponents {
/** HTTP 方法,大写 **/
method: string;
/** 请求路径(不含 query) **/
path: string;
/** unix 秒级时间戳字符串 **/
timestamp: string;
/** 一次性随机串 **/
nonce: string;
/** 请求体 SHA-256 hex **/
bodyHash: string;
}
/** # 函数
* 拼接待签名的 canonical 字符串
*/
export function canonicalString(c: SignatureComponents): string {
return [c.method.toUpperCase(), c.path, c.timestamp, c.nonce, c.bodyHash].join("\n");
}
/** # 函数
* 计算 HMAC-SHA256 签名 hex
*/
export async function computeSignature(secret: string, c: SignatureComponents): Promise<string> {
return hmacSha256Hex(secret, canonicalString(c));
}
/** # 函数
* Node 端:为发往 Console 的请求生成全部签名请求头
* 参数: secret - node credential secret;nodeId - 节点 ID;method/url 同 HTTP 语义;body - 原始请求体
* 返回值: 可直接放入 fetch headers 的对象
*/
export async function signNodeRequest(
secret: string,
nodeId: string,
method: string,
url: string,
body: Uint8Array,
): Promise<Record<string, string>> {
const u = new URL(url);
const timestamp = String(Math.floor(Date.now() / 1000));
const nonce = randomNonce();
const bh = await bodyHash(body);
const signature = await computeSignature(secret, {
method: method.toUpperCase(),
path: u.pathname,
timestamp,
nonce,
bodyHash: bh,
});
return {
"Authorization": `WmailHub-Node ${nodeId}`,
"X-WmailHub-Timestamp": timestamp,
"X-WmailHub-Nonce": nonce,
"X-WmailHub-Body-SHA256": bh,
"X-WmailHub-Signature": signature,
"X-WmailHub-Protocol": PROTOCOL_VERSION,
};
}
/** # 成员变量: verifyNodeRequest 的输入 **/
export interface VerifyNodeRequestInput {
/** 请求方声明的 credential secret(Console 从库中按 node_id 取出) **/
secret: string;
/** HTTP 方法 **/
method: string;
/** 完整 URL 或路径(用于取 pathname) **/
url: string;
/** 原始请求体 **/
body: Uint8Array;
/** 请求头(大小写不敏感,由调用方传入 Headers 或普通对象) **/
headers: Headers;
/** 当前 unix 秒,测试可注入 **/
now?: number;
}
/** # 成员变量: verifyNodeRequest 的结果 **/
export type VerifyNodeResult =
| { ok: true; timestamp: number; nonce: string }
| { ok: false; status: 400 | 401; error: string };
/** # 函数
* Console 端:校验 Node 请求头签名完整性(不含 nonce 防重放与 credential 撤销检查,
* 这两项由调用方结合数据库完成:nonce 用唯一索引入库,credential 状态查询后拒绝)。
*/
export async function verifyNodeRequest(input: VerifyNodeRequestInput): Promise<VerifyNodeResult> {
const h = input.headers;
const auth = h.get("Authorization") ?? "";
const m = /^WmailHub-Node\s+(.+)$/.exec(auth);
if (!m) return { ok: false, status: 401, error: "missing node authorization" };
const timestamp = h.get("X-WmailHub-Timestamp") ?? "";
const nonce = h.get("X-WmailHub-Nonce") ?? "";
const bodySha = h.get("X-WmailHub-Body-SHA256") ?? "";
const signature = h.get("X-WmailHub-Signature") ?? "";
const protocol = h.get("X-WmailHub-Protocol") ?? "";
if (!timestamp || !nonce || !bodySha || !signature) {
return { ok: false, status: 401, error: "missing signature headers" };
}
if (protocol !== PROTOCOL_VERSION) {
return { ok: false, status: 400, error: `unsupported protocol version: ${protocol}` };
}
const ts = Number(timestamp);
const now = input.now ?? Math.floor(Date.now() / 1000);
if (!Number.isInteger(ts) || Math.abs(now - ts) > TIMESTAMP_WINDOW_SECONDS) {
return { ok: false, status: 401, error: "timestamp out of window" };
}
const actualBodyHash = await bodyHash(input.body);
if (!timingSafeEqualHex(actualBodyHash, bodySha)) {
return { ok: false, status: 401, error: "body hash mismatch" };
}
const expected = await computeSignature(input.secret, {
method: input.method.toUpperCase(),
path: new URL(input.url, "https://local").pathname,
timestamp,
nonce,
bodyHash: bodySha,
});
if (!timingSafeEqualEqual(expected, signature)) {
return { ok: false, status: 401, error: "invalid signature" };
}
return { ok: true, timestamp: ts, nonce };
}
function timingSafeEqualEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
/** # 函数
* 生成一次性的随机 nonce(32 hex)
*/
export function randomNonce(): string {
const bytes = crypto.getRandomValues(new Uint8Array(16));
let out = "";
for (const b of bytes) out += b.toString(16).padStart(2, "0");
return out;
}
+135
View File
@@ -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;
}