mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
test(security): add webhook hardening regressions
This commit is contained in:
66
extensions/feishu/src/config-schema.test.ts
Normal file
66
extensions/feishu/src/config-schema.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
174
extensions/feishu/src/monitor.webhook-security.test.ts
Normal file
174
extensions/feishu/src/monitor.webhook-security.test.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user