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,141 @@
|
||||
/** 协议与密钥测试:HMAC 签名、防重放、防篡改、时间窗、wmh_ Key 格式 */
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
canonicalString,
|
||||
computeSignature,
|
||||
generateWmailKey,
|
||||
isValidWmailKey,
|
||||
verifyNodeRequest,
|
||||
} from "../shared/src/index.ts";
|
||||
import { makeTestEnv, signedEmailRequest, TEST_MAIL } from "./helpers.ts";
|
||||
|
||||
describe("wmh_ Key 规范", () => {
|
||||
test("生成的 Key 以 wmh_ 开头且总长 32", () => {
|
||||
const key = generateWmailKey();
|
||||
expect(key.startsWith("wmh_")).toBe(true);
|
||||
expect(key.length).toBe(32);
|
||||
expect(isValidWmailKey(key)).toBe(true);
|
||||
});
|
||||
|
||||
test("Key 随机且格式校验拒绝非法输入", () => {
|
||||
const a = generateWmailKey();
|
||||
const b = generateWmailKey();
|
||||
expect(a).not.toBe(b);
|
||||
expect(isValidWmailKey("wmh_short")).toBe(false);
|
||||
expect(isValidWmailKey("other_" + "x".repeat(28))).toBe(false);
|
||||
expect(isValidWmailKey("wmh_" + "x".repeat(27))).toBe(false); // 31
|
||||
expect(isValidWmailKey("wmh_" + "x".repeat(29))).toBe(false); // 33
|
||||
expect(isValidWmailKey("wmh_" + "!".repeat(28))).toBe(false); // 非法字符
|
||||
});
|
||||
});
|
||||
|
||||
describe("HMAC 请求签名", () => {
|
||||
test("canonical 串格式符合 docs/04", () => {
|
||||
const c = canonicalString({
|
||||
method: "POST",
|
||||
path: "/api/x",
|
||||
timestamp: "123",
|
||||
nonce: "n",
|
||||
bodyHash: "h",
|
||||
});
|
||||
expect(c).toBe("POST\n/api/x\n123\nn\nh");
|
||||
});
|
||||
|
||||
test("正确签名可通过校验", async () => {
|
||||
const body = new TextEncoder().encode("hello");
|
||||
const headers = new Headers({
|
||||
"Authorization": "WmailHub-Node node_1",
|
||||
"X-WmailHub-Timestamp": String(Math.floor(Date.now() / 1000)),
|
||||
"X-WmailHub-Nonce": "nonce-1",
|
||||
"X-WmailHub-Body-SHA256": await (async () => {
|
||||
const { sha256Hex } = await import("../shared/src/index.ts");
|
||||
return sha256Hex(body);
|
||||
})(),
|
||||
"X-WmailHub-Signature": await computeSignature("s", {
|
||||
method: "POST",
|
||||
path: "/p",
|
||||
timestamp: String(Math.floor(Date.now() / 1000)),
|
||||
nonce: "nonce-1",
|
||||
bodyHash: await (async () => {
|
||||
const { sha256Hex } = await import("../shared/src/index.ts");
|
||||
return sha256Hex(body);
|
||||
})(),
|
||||
}),
|
||||
"X-WmailHub-Protocol": "1",
|
||||
});
|
||||
const result = await verifyNodeRequest({
|
||||
secret: "s",
|
||||
method: "POST",
|
||||
url: "http://x/p",
|
||||
body,
|
||||
headers,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("错误 secret 签名被拒(伪造 Node 请求)", async () => {
|
||||
const test = await makeTestEnv();
|
||||
const body = new TextEncoder().encode(TEST_MAIL);
|
||||
const req = await signedEmailRequest(test, body, { secret: "wns_wrong_secret" });
|
||||
const resp = await test.app.fetch(req, test.env);
|
||||
expect(resp.status).toBe(401);
|
||||
});
|
||||
|
||||
test("body 篡改被拒", async () => {
|
||||
const test = await makeTestEnv();
|
||||
const body = new TextEncoder().encode(TEST_MAIL);
|
||||
const tampered = new TextEncoder().encode(TEST_MAIL + "injected");
|
||||
const req = await signedEmailRequest(test, body, { tamperBody: tampered });
|
||||
const resp = await test.app.fetch(req, test.env);
|
||||
expect(resp.status).toBe(401);
|
||||
});
|
||||
|
||||
test("过期 timestamp 超窗被拒", async () => {
|
||||
const test = await makeTestEnv();
|
||||
const body = new TextEncoder().encode(TEST_MAIL);
|
||||
const old = String(Math.floor(Date.now() / 1000) - 3600); // 1 小时前
|
||||
const req = await signedEmailRequest(test, body, { timestamp: old });
|
||||
const resp = await test.app.fetch(req, test.env);
|
||||
expect(resp.status).toBe(401);
|
||||
const data = (await resp.json()) as { error: string };
|
||||
expect(data.error).toContain("timestamp");
|
||||
});
|
||||
|
||||
test("nonce 重放被拒(replay attack)", async () => {
|
||||
const test = await makeTestEnv();
|
||||
const body = new TextEncoder().encode(TEST_MAIL);
|
||||
const req1 = await signedEmailRequest(test, body);
|
||||
const resp1 = await test.app.fetch(req1, test.env);
|
||||
expect(resp1.status).toBe(202);
|
||||
// 取第一次请求的 nonce 重放(重新签名同一 nonce)
|
||||
const url = `http://localhost/api/internal/nodes/${test.nodeId}/emails`;
|
||||
const { signNodeRequest } = await import("../shared/src/index.ts");
|
||||
const headers = await signNodeRequest(test.nodeSecret, test.nodeId, "POST", url, body);
|
||||
headers["Content-Type"] = "message/rfc822";
|
||||
const resp2a = await test.app.fetch(new Request(url, { method: "POST", headers, body }), test.env);
|
||||
expect(resp2a.status).toBe(202);
|
||||
// 抓第二次请求的完整头,原样重发构成 replay(同 nonce 同签名)
|
||||
const headers3 = await signNodeRequest(test.nodeSecret, test.nodeId, "POST", url, body);
|
||||
headers3["Content-Type"] = "message/rfc822";
|
||||
const replayReq = () => new Request(url, { method: "POST", headers: headers3, body });
|
||||
const respFirst = await test.app.fetch(replayReq(), test.env);
|
||||
expect(respFirst.status).toBe(202);
|
||||
const resp3 = await test.app.fetch(replayReq(), test.env);
|
||||
expect(resp3.status).toBe(401);
|
||||
const data = (await resp3.json()) as { error: string };
|
||||
expect(data.error).toContain("replay");
|
||||
});
|
||||
|
||||
test("错误协议版本被拒", async () => {
|
||||
const test = await makeTestEnv();
|
||||
const body = new TextEncoder().encode(TEST_MAIL);
|
||||
const req = await signedEmailRequest(test, body);
|
||||
const headers = new Headers(req.headers);
|
||||
headers.set("X-WmailHub-Protocol", "9");
|
||||
const resp = await test.app.fetch(
|
||||
new Request(req.url, { method: "POST", headers, body }),
|
||||
test.env,
|
||||
);
|
||||
expect(resp.status).toBe(400);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user