From 5574eb6b35373a149081c0bc474aec180ee33e7f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 11:28:05 +0000 Subject: [PATCH] fix(feishu): harden onboarding and webhook validation --- .../feishu/src/bot.checkBotMentioned.test.ts | 53 +++---- extensions/feishu/src/bot.test.ts | 66 +++------ extensions/feishu/src/config-schema.test.ts | 22 +++ extensions/feishu/src/config-schema.ts | 68 ++++----- extensions/feishu/src/media.test.ts | 26 ++-- .../src/monitor.webhook-security.test.ts | 139 ++++++++++-------- extensions/feishu/src/onboarding.ts | 64 ++++---- 7 files changed, 202 insertions(+), 236 deletions(-) diff --git a/extensions/feishu/src/bot.checkBotMentioned.test.ts b/extensions/feishu/src/bot.checkBotMentioned.test.ts index a6233e05350..c88b32925e1 100644 --- a/extensions/feishu/src/bot.checkBotMentioned.test.ts +++ b/extensions/feishu/src/bot.checkBotMentioned.test.ts @@ -22,6 +22,20 @@ function makeEvent( }; } +function makePostEvent(content: unknown) { + return { + sender: { sender_id: { user_id: "u1", open_id: "ou_sender" } }, + message: { + message_id: "msg_1", + chat_id: "oc_chat1", + chat_type: "group", + message_type: "post", + content: JSON.stringify(content), + mentions: [], + }, + }; +} + describe("parseFeishuMessageEvent – mentionedBot", () => { const BOT_OPEN_ID = "ou_bot_123"; @@ -85,64 +99,31 @@ describe("parseFeishuMessageEvent – mentionedBot", () => { it("returns mentionedBot=true for post message with at (no top-level mentions)", () => { const BOT_OPEN_ID = "ou_bot_123"; - const postContent = JSON.stringify({ + const event = makePostEvent({ content: [ [{ tag: "at", user_id: BOT_OPEN_ID, user_name: "claw" }], [{ tag: "text", text: "What does this document say" }], ], }); - const event = { - sender: { sender_id: { user_id: "u1", open_id: "ou_sender" } }, - message: { - message_id: "msg_1", - chat_id: "oc_chat1", - chat_type: "group", - message_type: "post", - content: postContent, - mentions: [], - }, - }; const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID); expect(ctx.mentionedBot).toBe(true); }); it("returns mentionedBot=false for post message with no at", () => { - const postContent = JSON.stringify({ + const event = makePostEvent({ content: [[{ tag: "text", text: "hello" }]], }); - const event = { - sender: { sender_id: { user_id: "u1", open_id: "ou_sender" } }, - message: { - message_id: "msg_1", - chat_id: "oc_chat1", - chat_type: "group", - message_type: "post", - content: postContent, - mentions: [], - }, - }; const ctx = parseFeishuMessageEvent(event as any, "ou_bot_123"); expect(ctx.mentionedBot).toBe(false); }); it("returns mentionedBot=false for post message with at for another user", () => { - const postContent = JSON.stringify({ + const event = makePostEvent({ content: [ [{ tag: "at", user_id: "ou_other", user_name: "other" }], [{ tag: "text", text: "hello" }], ], }); - const event = { - sender: { sender_id: { user_id: "u1", open_id: "ou_sender" } }, - message: { - message_id: "msg_1", - chat_id: "oc_chat1", - chat_type: "group", - message_type: "post", - content: postContent, - mentions: [], - }, - }; const ctx = parseFeishuMessageEvent(event as any, "ou_bot_123"); expect(ctx.mentionedBot).toBe(false); }); diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index b9cd691cbb2..0daebe19d04 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -25,6 +25,24 @@ vi.mock("./send.js", () => ({ getMessageFeishu: mockGetMessageFeishu, })); +function createRuntimeEnv(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number): never => { + throw new Error(`exit ${code}`); + }), + } as RuntimeEnv; +} + +async function dispatchMessage(params: { cfg: ClawdbotConfig; event: FeishuMessageEvent }) { + await handleFeishuMessage({ + cfg: params.cfg, + event: params.event, + runtime: createRuntimeEnv(), + }); +} + describe("handleFeishuMessage command authorization", () => { const mockFinalizeInboundContext = vi.fn((ctx: unknown) => ctx); const mockDispatchReplyFromConfig = vi @@ -96,17 +114,7 @@ describe("handleFeishuMessage command authorization", () => { }, }; - await handleFeishuMessage({ - cfg, - event, - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - } as RuntimeEnv, - }); + await dispatchMessage({ cfg, event }); expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({ useAccessGroups: true, @@ -151,17 +159,7 @@ describe("handleFeishuMessage command authorization", () => { }, }; - await handleFeishuMessage({ - cfg, - event, - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - } as RuntimeEnv, - }); + await dispatchMessage({ cfg, event }); expect(mockReadAllowFromStore).toHaveBeenCalledWith("feishu"); expect(mockResolveCommandAuthorizedFromAuthorizers).not.toHaveBeenCalled(); @@ -198,17 +196,7 @@ describe("handleFeishuMessage command authorization", () => { }, }; - await handleFeishuMessage({ - cfg, - event, - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - } as RuntimeEnv, - }); + await dispatchMessage({ cfg, event }); expect(mockUpsertPairingRequest).toHaveBeenCalledWith({ channel: "feishu", @@ -262,17 +250,7 @@ describe("handleFeishuMessage command authorization", () => { }, }; - await handleFeishuMessage({ - cfg, - event, - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - } as RuntimeEnv, - }); + await dispatchMessage({ cfg, event }); expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({ useAccessGroups: true, diff --git a/extensions/feishu/src/config-schema.test.ts b/extensions/feishu/src/config-schema.test.ts index 942d0c8853c..64a278c4afe 100644 --- a/extensions/feishu/src/config-schema.test.ts +++ b/extensions/feishu/src/config-schema.test.ts @@ -2,6 +2,28 @@ import { describe, expect, it } from "vitest"; import { FeishuConfigSchema } from "./config-schema.js"; describe("FeishuConfigSchema webhook validation", () => { + it("applies top-level defaults", () => { + const result = FeishuConfigSchema.parse({}); + expect(result.domain).toBe("feishu"); + expect(result.connectionMode).toBe("websocket"); + expect(result.webhookPath).toBe("/feishu/events"); + expect(result.dmPolicy).toBe("pairing"); + expect(result.groupPolicy).toBe("allowlist"); + expect(result.requireMention).toBe(true); + }); + + it("does not force top-level policy defaults into account config", () => { + const result = FeishuConfigSchema.parse({ + accounts: { + main: {}, + }, + }); + + expect(result.accounts?.main?.dmPolicy).toBeUndefined(); + expect(result.accounts?.main?.groupPolicy).toBeUndefined(); + expect(result.accounts?.main?.requireMention).toBeUndefined(); + }); + it("rejects top-level webhook mode without verificationToken", () => { const result = FeishuConfigSchema.safeParse({ connectionMode: "webhook", diff --git a/extensions/feishu/src/config-schema.ts b/extensions/feishu/src/config-schema.ts index b1e9fa24879..f5b08e13ee7 100644 --- a/extensions/feishu/src/config-schema.ts +++ b/extensions/feishu/src/config-schema.ts @@ -112,6 +112,31 @@ export const FeishuGroupSchema = z }) .strict(); +const FeishuSharedConfigShape = { + webhookHost: z.string().optional(), + webhookPort: z.number().int().positive().optional(), + capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, + configWrites: z.boolean().optional(), + dmPolicy: DmPolicySchema.optional(), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupPolicy: GroupPolicySchema.optional(), + groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), + requireMention: z.boolean().optional(), + groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(), + historyLimit: z.number().int().min(0).optional(), + dmHistoryLimit: z.number().int().min(0).optional(), + dms: z.record(z.string(), DmConfigSchema).optional(), + textChunkLimit: z.number().int().positive().optional(), + chunkMode: z.enum(["length", "newline"]).optional(), + blockStreamingCoalesce: BlockStreamingCoalesceSchema, + mediaMaxMb: z.number().positive().optional(), + heartbeat: ChannelHeartbeatVisibilitySchema, + renderMode: RenderModeSchema, + streaming: StreamingModeSchema, + tools: FeishuToolsConfigSchema, +}; + /** * Per-account configuration. * All fields are optional - missing fields inherit from top-level config. @@ -127,28 +152,7 @@ export const FeishuAccountConfigSchema = z domain: FeishuDomainSchema.optional(), connectionMode: FeishuConnectionModeSchema.optional(), webhookPath: z.string().optional(), - webhookHost: z.string().optional(), - webhookPort: z.number().int().positive().optional(), - capabilities: z.array(z.string()).optional(), - markdown: MarkdownConfigSchema, - configWrites: z.boolean().optional(), - dmPolicy: DmPolicySchema.optional(), - allowFrom: z.array(z.union([z.string(), z.number()])).optional(), - groupPolicy: GroupPolicySchema.optional(), - groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), - requireMention: z.boolean().optional(), - groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(), - historyLimit: z.number().int().min(0).optional(), - dmHistoryLimit: z.number().int().min(0).optional(), - dms: z.record(z.string(), DmConfigSchema).optional(), - textChunkLimit: z.number().int().positive().optional(), - chunkMode: z.enum(["length", "newline"]).optional(), - blockStreamingCoalesce: BlockStreamingCoalesceSchema, - mediaMaxMb: z.number().positive().optional(), - heartbeat: ChannelHeartbeatVisibilitySchema, - renderMode: RenderModeSchema, - streaming: StreamingModeSchema, // Enable streaming card mode (default: true) - tools: FeishuToolsConfigSchema, + ...FeishuSharedConfigShape, }) .strict(); @@ -163,29 +167,11 @@ export const FeishuConfigSchema = z domain: FeishuDomainSchema.optional().default("feishu"), connectionMode: FeishuConnectionModeSchema.optional().default("websocket"), webhookPath: z.string().optional().default("/feishu/events"), - webhookHost: z.string().optional(), - webhookPort: z.number().int().positive().optional(), - capabilities: z.array(z.string()).optional(), - markdown: MarkdownConfigSchema, - configWrites: z.boolean().optional(), + ...FeishuSharedConfigShape, dmPolicy: DmPolicySchema.optional().default("pairing"), - allowFrom: z.array(z.union([z.string(), z.number()])).optional(), groupPolicy: GroupPolicySchema.optional().default("allowlist"), - groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), requireMention: z.boolean().optional().default(true), - groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(), topicSessionMode: TopicSessionModeSchema, - historyLimit: z.number().int().min(0).optional(), - dmHistoryLimit: z.number().int().min(0).optional(), - dms: z.record(z.string(), DmConfigSchema).optional(), - textChunkLimit: z.number().int().positive().optional(), - chunkMode: z.enum(["length", "newline"]).optional(), - blockStreamingCoalesce: BlockStreamingCoalesceSchema, - mediaMaxMb: z.number().positive().optional(), - heartbeat: ChannelHeartbeatVisibilitySchema, - renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown - streaming: StreamingModeSchema, // Enable streaming card mode (default: true) - tools: FeishuToolsConfigSchema, // Dynamic agent creation for DM users dynamicAgentCreation: DynamicAgentCreationSchema, // Multi-account configuration diff --git a/extensions/feishu/src/media.test.ts b/extensions/feishu/src/media.test.ts index b9e97703a1b..5851e849037 100644 --- a/extensions/feishu/src/media.test.ts +++ b/extensions/feishu/src/media.test.ts @@ -38,6 +38,16 @@ vi.mock("./runtime.js", () => ({ import { downloadImageFeishu, downloadMessageResourceFeishu, sendMediaFeishu } from "./media.js"; +function expectPathIsolatedToTmpRoot(pathValue: string, key: string): void { + expect(pathValue).not.toContain(key); + expect(pathValue).not.toContain(".."); + + const tmpRoot = path.resolve(os.tmpdir()); + const resolved = path.resolve(pathValue); + const rel = path.relative(tmpRoot, resolved); + expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false); +} + describe("sendMediaFeishu msg_type routing", () => { beforeEach(() => { vi.clearAllMocks(); @@ -217,13 +227,7 @@ describe("sendMediaFeishu msg_type routing", () => { expect(result.buffer).toEqual(Buffer.from("image-data")); expect(capturedPath).toBeDefined(); - expect(capturedPath).not.toContain(imageKey); - expect(capturedPath).not.toContain(".."); - - const tmpRoot = path.resolve(os.tmpdir()); - const resolved = path.resolve(capturedPath as string); - const rel = path.relative(tmpRoot, resolved); - expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false); + expectPathIsolatedToTmpRoot(capturedPath as string, imageKey); }); it("uses isolated temp paths for message resource downloads", async () => { @@ -246,13 +250,7 @@ describe("sendMediaFeishu msg_type routing", () => { expect(result.buffer).toEqual(Buffer.from("resource-data")); expect(capturedPath).toBeDefined(); - expect(capturedPath).not.toContain(fileKey); - expect(capturedPath).not.toContain(".."); - - const tmpRoot = path.resolve(os.tmpdir()); - const resolved = path.resolve(capturedPath as string); - const rel = path.relative(tmpRoot, resolved); - expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false); + expectPathIsolatedToTmpRoot(capturedPath as string, fileKey); }); it("rejects invalid image keys before calling feishu api", async () => { diff --git a/extensions/feishu/src/monitor.webhook-security.test.ts b/extensions/feishu/src/monitor.webhook-security.test.ts index b304ee6ed40..97637e75efe 100644 --- a/extensions/feishu/src/monitor.webhook-security.test.ts +++ b/extensions/feishu/src/monitor.webhook-security.test.ts @@ -78,6 +78,41 @@ function buildConfig(params: { } 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(() => { stopFeishuMonitor(); }); @@ -99,76 +134,50 @@ describe("Feishu webhook security hardening", () => { 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", - }); + 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: "{}", + }); - 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; + 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" }); - const port = await getFreePort(); - const path = "/hook-rate-limit"; - const cfg = buildConfig({ - accountId: "rate-limit", - path, - port, - verificationToken: "verify_token", - }); + 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; + } + } - 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; + expect(saw429).toBe(true); + }, + ); }); }); diff --git a/extensions/feishu/src/onboarding.ts b/extensions/feishu/src/onboarding.ts index a2cf02dd241..bb847ebabbe 100644 --- a/extensions/feishu/src/onboarding.ts +++ b/extensions/feishu/src/onboarding.ts @@ -104,6 +104,25 @@ async function noteFeishuCredentialHelp(prompter: WizardPrompter): Promise ); } +async function promptFeishuCredentials(prompter: WizardPrompter): Promise<{ + appId: string; + appSecret: string; +}> { + const appId = String( + await prompter.text({ + message: "Enter Feishu App ID", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + const appSecret = String( + await prompter.text({ + message: "Enter Feishu App Secret", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + return { appId, appSecret }; +} + function setFeishuGroupPolicy( cfg: ClawdbotConfig, groupPolicy: "open" | "allowlist" | "disabled", @@ -210,18 +229,9 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { }, }; } else { - appId = String( - await prompter.text({ - message: "Enter Feishu App ID", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - appSecret = String( - await prompter.text({ - message: "Enter Feishu App Secret", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); + const entered = await promptFeishuCredentials(prompter); + appId = entered.appId; + appSecret = entered.appSecret; } } else if (hasConfigCreds) { const keep = await prompter.confirm({ @@ -229,32 +239,14 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { initialValue: true, }); if (!keep) { - appId = String( - await prompter.text({ - message: "Enter Feishu App ID", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - appSecret = String( - await prompter.text({ - message: "Enter Feishu App Secret", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); + const entered = await promptFeishuCredentials(prompter); + appId = entered.appId; + appSecret = entered.appSecret; } } else { - appId = String( - await prompter.text({ - message: "Enter Feishu App ID", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - appSecret = String( - await prompter.text({ - message: "Enter Feishu App Secret", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); + const entered = await promptFeishuCredentials(prompter); + appId = entered.appId; + appSecret = entered.appSecret; } if (appId && appSecret) {