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,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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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