fix(feishu): harden onboarding and webhook validation

This commit is contained in:
Peter Steinberger
2026-02-22 11:28:05 +00:00
parent 9e6125ea2f
commit 5574eb6b35
7 changed files with 202 additions and 236 deletions

View File

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

View File

@@ -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,

View File

@@ -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",

View File

@@ -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

View File

@@ -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 () => {

View File

@@ -78,6 +78,41 @@ function buildConfig(params: {
} as ClawdbotConfig;
}
async function withRunningWebhookMonitor(
params: {
accountId: string;
path: string;
verificationToken: string;
},
run: (url: string) => Promise<void>,
) {
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);
},
);
});
});

View File

@@ -104,6 +104,25 @@ async function noteFeishuCredentialHelp(prompter: WizardPrompter): Promise<void>
);
}
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) {