From aa267812d30ff0f11f15673534ca452fb0df3a0f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 13:30:59 +0100 Subject: [PATCH] test(security): add webhook hardening regressions --- extensions/feishu/src/config-schema.test.ts | 66 +++++++ .../src/monitor.webhook-security.test.ts | 174 ++++++++++++++++++ extensions/zalo/src/monitor.webhook.test.ts | 173 +++++++++++++++++ 3 files changed, 413 insertions(+) create mode 100644 extensions/feishu/src/config-schema.test.ts create mode 100644 extensions/feishu/src/monitor.webhook-security.test.ts diff --git a/extensions/feishu/src/config-schema.test.ts b/extensions/feishu/src/config-schema.test.ts new file mode 100644 index 0000000000..942d0c8853 --- /dev/null +++ b/extensions/feishu/src/config-schema.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { FeishuConfigSchema } from "./config-schema.js"; + +describe("FeishuConfigSchema webhook validation", () => { + it("rejects top-level webhook mode without verificationToken", () => { + const result = FeishuConfigSchema.safeParse({ + connectionMode: "webhook", + appId: "cli_top", + appSecret: "secret_top", + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some((issue) => issue.path.join(".") === "verificationToken"), + ).toBe(true); + } + }); + + it("accepts top-level webhook mode with verificationToken", () => { + const result = FeishuConfigSchema.safeParse({ + connectionMode: "webhook", + verificationToken: "token_top", + appId: "cli_top", + appSecret: "secret_top", + }); + + expect(result.success).toBe(true); + }); + + it("rejects account webhook mode without verificationToken", () => { + const result = FeishuConfigSchema.safeParse({ + accounts: { + main: { + connectionMode: "webhook", + appId: "cli_main", + appSecret: "secret_main", + }, + }, + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some( + (issue) => issue.path.join(".") === "accounts.main.verificationToken", + ), + ).toBe(true); + } + }); + + it("accepts account webhook mode inheriting top-level verificationToken", () => { + const result = FeishuConfigSchema.safeParse({ + verificationToken: "token_top", + accounts: { + main: { + connectionMode: "webhook", + appId: "cli_main", + appSecret: "secret_main", + }, + }, + }); + + expect(result.success).toBe(true); + }); +}); diff --git a/extensions/feishu/src/monitor.webhook-security.test.ts b/extensions/feishu/src/monitor.webhook-security.test.ts new file mode 100644 index 0000000000..b304ee6ed4 --- /dev/null +++ b/extensions/feishu/src/monitor.webhook-security.test.ts @@ -0,0 +1,174 @@ +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import type { ClawdbotConfig } from "openclaw/plugin-sdk"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const probeFeishuMock = vi.hoisted(() => vi.fn()); + +vi.mock("@larksuiteoapi/node-sdk", () => ({ + adaptDefault: vi.fn( + () => (_req: unknown, res: { statusCode?: number; end: (s: string) => void }) => { + res.statusCode = 200; + res.end("ok"); + }, + ), +})); + +vi.mock("./probe.js", () => ({ + probeFeishu: probeFeishuMock, +})); + +vi.mock("./client.js", () => ({ + createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })), + createEventDispatcher: vi.fn(() => ({ register: vi.fn() })), +})); + +import { 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; +} + +afterEach(() => { + 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" }); + const port = await getFreePort(); + const path = "/hook-content-type"; + const cfg = buildConfig({ + accountId: "content-type", + path, + port, + verificationToken: "verify_token", + }); + + const abortController = new AbortController(); + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + const monitorPromise = monitorFeishuProvider({ + config: cfg, + runtime, + abortSignal: abortController.signal, + }); + + await waitUntilServerReady(`http://127.0.0.1:${port}${path}`); + + const response = await fetch(`http://127.0.0.1:${port}${path}`, { + method: "POST", + headers: { "content-type": "text/plain" }, + body: "{}", + }); + + expect(response.status).toBe(415); + expect(await response.text()).toBe("Unsupported Media Type"); + + abortController.abort(); + await monitorPromise; + }); + + it("rate limits webhook burst traffic with 429", async () => { + probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" }); + const port = await getFreePort(); + const path = "/hook-rate-limit"; + const cfg = buildConfig({ + accountId: "rate-limit", + path, + port, + verificationToken: "verify_token", + }); + + const abortController = new AbortController(); + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + const monitorPromise = monitorFeishuProvider({ + config: cfg, + runtime, + abortSignal: abortController.signal, + }); + + await waitUntilServerReady(`http://127.0.0.1:${port}${path}`); + + let saw429 = false; + for (let i = 0; i < 130; i += 1) { + const response = await fetch(`http://127.0.0.1:${port}${path}`, { + 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); + + abortController.abort(); + await monitorPromise; + }); +}); diff --git a/extensions/zalo/src/monitor.webhook.test.ts b/extensions/zalo/src/monitor.webhook.test.ts index 91e1be8c48..97162544b6 100644 --- a/extensions/zalo/src/monitor.webhook.test.ts +++ b/extensions/zalo/src/monitor.webhook.test.ts @@ -56,11 +56,13 @@ describe("handleZaloWebhookRequest", () => { method: "POST", headers: { "x-bot-api-secret-token": "secret", + "content-type": "application/json", }, body: "null", }); expect(response.status).toBe(400); + expect(await response.text()).toBe("Bad Request"); }, ); } finally { @@ -131,4 +133,175 @@ describe("handleZaloWebhookRequest", () => { unregisterB(); } }); + + it("returns 415 for non-json content-type", async () => { + const core = {} as PluginRuntime; + const account: ResolvedZaloAccount = { + accountId: "default", + enabled: true, + token: "tok", + tokenSource: "config", + config: {}, + }; + const unregister = registerZaloWebhookTarget({ + token: "tok", + account, + config: {} as OpenClawConfig, + runtime: {}, + core, + secret: "secret", + path: "/hook-content-type", + mediaMaxMb: 5, + }); + + try { + await withServer( + async (req, res) => { + const handled = await handleZaloWebhookRequest(req, res); + if (!handled) { + res.statusCode = 404; + res.end("not found"); + } + }, + async (baseUrl) => { + const response = await fetch(`${baseUrl}/hook-content-type`, { + method: "POST", + headers: { + "x-bot-api-secret-token": "secret", + "content-type": "text/plain", + }, + body: "{}", + }); + + expect(response.status).toBe(415); + }, + ); + } finally { + unregister(); + } + }); + + it("deduplicates webhook replay by event_name + message_id", async () => { + const core = {} as PluginRuntime; + const account: ResolvedZaloAccount = { + accountId: "default", + enabled: true, + token: "tok", + tokenSource: "config", + config: {}, + }; + const sink = vi.fn(); + const unregister = registerZaloWebhookTarget({ + token: "tok", + account, + config: {} as OpenClawConfig, + runtime: {}, + core, + secret: "secret", + path: "/hook-replay", + mediaMaxMb: 5, + statusSink: sink, + }); + + const payload = { + event_name: "message.text.received", + message: { + from: { id: "123" }, + chat: { id: "123", chat_type: "PRIVATE" }, + message_id: "msg-replay-1", + date: Math.floor(Date.now() / 1000), + text: "hello", + }, + }; + + try { + await withServer( + async (req, res) => { + const handled = await handleZaloWebhookRequest(req, res); + if (!handled) { + res.statusCode = 404; + res.end("not found"); + } + }, + async (baseUrl) => { + const first = await fetch(`${baseUrl}/hook-replay`, { + method: "POST", + headers: { + "x-bot-api-secret-token": "secret", + "content-type": "application/json", + }, + body: JSON.stringify(payload), + }); + const second = await fetch(`${baseUrl}/hook-replay`, { + method: "POST", + headers: { + "x-bot-api-secret-token": "secret", + "content-type": "application/json", + }, + body: JSON.stringify(payload), + }); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(sink).toHaveBeenCalledTimes(1); + }, + ); + } finally { + unregister(); + } + }); + + it("returns 429 when per-path request rate exceeds threshold", async () => { + const core = {} as PluginRuntime; + const account: ResolvedZaloAccount = { + accountId: "default", + enabled: true, + token: "tok", + tokenSource: "config", + config: {}, + }; + const unregister = registerZaloWebhookTarget({ + token: "tok", + account, + config: {} as OpenClawConfig, + runtime: {}, + core, + secret: "secret", + path: "/hook-rate", + mediaMaxMb: 5, + }); + + try { + await withServer( + async (req, res) => { + const handled = await handleZaloWebhookRequest(req, res); + if (!handled) { + res.statusCode = 404; + res.end("not found"); + } + }, + async (baseUrl) => { + let saw429 = false; + for (let i = 0; i < 130; i += 1) { + const response = await fetch(`${baseUrl}/hook-rate`, { + method: "POST", + headers: { + "x-bot-api-secret-token": "secret", + "content-type": "application/json", + }, + body: "{}", + }); + if (response.status === 429) { + saw429 = true; + break; + } + } + + expect(saw429).toBe(true); + }, + ); + } finally { + unregister(); + } + }); });