test(security): add webhook hardening regressions

This commit is contained in:
Peter Steinberger
2026-02-19 13:30:59 +01:00
parent a23e0d5140
commit aa267812d3
3 changed files with 413 additions and 0 deletions

View File

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

View File

@@ -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<number> {
const server = createServer();
await new Promise<void>((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<void>((resolve) => server.close(() => resolve()));
return address.port;
}
async function waitUntilServerReady(url: string): Promise<void> {
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;
});
});

View File

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