import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; import { afterEach, describe, expect, it, vi } from "vitest"; const probeFeishuMock = vi.hoisted(() => vi.fn()); vi.mock("./probe.js", () => ({ probeFeishu: probeFeishuMock, })); vi.mock("./client.js", () => ({ createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })), createEventDispatcher: vi.fn(() => ({ register: vi.fn() })), })); vi.mock("./runtime.js", () => ({ getFeishuRuntime: () => ({ channel: { debounce: { resolveInboundDebounceMs: () => 0, createInboundDebouncer: () => ({ enqueue: async () => {}, flushKey: async () => {}, }), }, text: { hasControlCommand: () => false, }, }, }), })); vi.mock("@larksuiteoapi/node-sdk", () => ({ adaptDefault: vi.fn( () => (_req: unknown, res: { statusCode?: number; end: (s: string) => void }) => { res.statusCode = 200; res.end("ok"); }, ), })); import { clearFeishuWebhookRateLimitStateForTest, getFeishuWebhookRateLimitStateSizeForTest, isWebhookRateLimitedForTest, monitorFeishuProvider, stopFeishuMonitor, } from "./monitor.js"; async function getFreePort(): Promise { const server = createServer(); await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); const address = server.address() as AddressInfo | null; if (!address) { throw new Error("missing server address"); } await new Promise((resolve) => server.close(() => resolve())); return address.port; } async function waitUntilServerReady(url: string): Promise { for (let i = 0; i < 50; i += 1) { try { const response = await fetch(url, { method: "GET" }); if (response.status >= 200 && response.status < 500) { return; } } catch { // retry } await new Promise((resolve) => setTimeout(resolve, 20)); } throw new Error(`server did not start: ${url}`); } function buildConfig(params: { accountId: string; path: string; port: number; verificationToken?: string; }): ClawdbotConfig { return { channels: { feishu: { enabled: true, accounts: { [params.accountId]: { enabled: true, appId: "cli_test", appSecret: "secret_test", connectionMode: "webhook", webhookHost: "127.0.0.1", webhookPort: params.port, webhookPath: params.path, verificationToken: params.verificationToken, }, }, }, }, } as ClawdbotConfig; } async function withRunningWebhookMonitor( params: { accountId: string; path: string; verificationToken: string; }, run: (url: string) => Promise, ) { const port = await getFreePort(); const cfg = buildConfig({ accountId: params.accountId, path: params.path, port, verificationToken: params.verificationToken, }); const abortController = new AbortController(); const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; const monitorPromise = monitorFeishuProvider({ config: cfg, runtime, abortSignal: abortController.signal, }); const url = `http://127.0.0.1:${port}${params.path}`; await waitUntilServerReady(url); try { await run(url); } finally { abortController.abort(); await monitorPromise; } } afterEach(() => { clearFeishuWebhookRateLimitStateForTest(); stopFeishuMonitor(); }); describe("Feishu webhook security hardening", () => { it("rejects webhook mode without verificationToken", async () => { probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" }); const cfg = buildConfig({ accountId: "missing-token", path: "/hook-missing-token", port: await getFreePort(), }); await expect(monitorFeishuProvider({ config: cfg })).rejects.toThrow( /requires verificationToken/i, ); }); it("returns 415 for POST requests without json content type", async () => { probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" }); await withRunningWebhookMonitor( { accountId: "content-type", path: "/hook-content-type", verificationToken: "verify_token", }, async (url) => { const response = await fetch(url, { method: "POST", headers: { "content-type": "text/plain" }, body: "{}", }); expect(response.status).toBe(415); expect(await response.text()).toBe("Unsupported Media Type"); }, ); }); it("rate limits webhook burst traffic with 429", async () => { probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" }); await withRunningWebhookMonitor( { accountId: "rate-limit", path: "/hook-rate-limit", verificationToken: "verify_token", }, async (url) => { let saw429 = false; for (let i = 0; i < 130; i += 1) { const response = await fetch(url, { method: "POST", headers: { "content-type": "text/plain" }, body: "{}", }); if (response.status === 429) { saw429 = true; expect(await response.text()).toBe("Too Many Requests"); break; } } expect(saw429).toBe(true); }, ); }); it("caps tracked webhook rate-limit keys to prevent unbounded growth", () => { const now = 1_000_000; for (let i = 0; i < 4_500; i += 1) { isWebhookRateLimitedForTest(`/feishu-rate-limit:key-${i}`, now); } expect(getFeishuWebhookRateLimitStateSizeForTest()).toBeLessThanOrEqual(4_096); }); it("prunes stale webhook rate-limit state after window elapses", () => { const now = 2_000_000; for (let i = 0; i < 100; i += 1) { isWebhookRateLimitedForTest(`/feishu-rate-limit-stale:key-${i}`, now); } expect(getFeishuWebhookRateLimitStateSizeForTest()).toBe(100); isWebhookRateLimitedForTest("/feishu-rate-limit-stale:fresh", now + 60_001); expect(getFeishuWebhookRateLimitStateSizeForTest()).toBe(1); }); });