@openclaw/feishu 2026.2.17 → 2026.2.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/feishu",
3
- "version": "2026.2.17",
3
+ "version": "2026.2.21",
4
4
  "description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -5,6 +5,7 @@ import { parseFeishuMessageEvent } from "./bot.js";
5
5
  function makeEvent(
6
6
  chatType: "p2p" | "group",
7
7
  mentions?: Array<{ key: string; name: string; id: { open_id?: string } }>,
8
+ text = "hello",
8
9
  ) {
9
10
  return {
10
11
  sender: {
@@ -15,7 +16,7 @@ function makeEvent(
15
16
  chat_id: "oc_chat1",
16
17
  chat_type: chatType,
17
18
  message_type: "text",
18
- content: JSON.stringify({ text: "hello" }),
19
+ content: JSON.stringify({ text }),
19
20
  mentions,
20
21
  },
21
22
  };
@@ -62,6 +63,26 @@ describe("parseFeishuMessageEvent – mentionedBot", () => {
62
63
  expect(ctx.mentionedBot).toBe(false);
63
64
  });
64
65
 
66
+ it("treats mention.name regex metacharacters as literals when stripping", () => {
67
+ const event = makeEvent(
68
+ "group",
69
+ [{ key: "@_bot_1", name: ".*", id: { open_id: BOT_OPEN_ID } }],
70
+ "@NotBot hello",
71
+ );
72
+ const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
73
+ expect(ctx.content).toBe("@NotBot hello");
74
+ });
75
+
76
+ it("treats mention.key regex metacharacters as literals when stripping", () => {
77
+ const event = makeEvent(
78
+ "group",
79
+ [{ key: ".*", name: "Bot", id: { open_id: BOT_OPEN_ID } }],
80
+ "hello world",
81
+ );
82
+ const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
83
+ expect(ctx.content).toBe("hello world");
84
+ });
85
+
65
86
  it("returns mentionedBot=true for post message with at (no top-level mentions)", () => {
66
87
  const BOT_OPEN_ID = "ou_bot_123";
67
88
  const postContent = JSON.stringify({
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { stripBotMention, type FeishuMessageEvent } from "./bot.js";
3
+
4
+ type Mentions = FeishuMessageEvent["message"]["mentions"];
5
+
6
+ describe("stripBotMention", () => {
7
+ it("returns original text when mentions are missing", () => {
8
+ expect(stripBotMention("hello world", undefined)).toBe("hello world");
9
+ });
10
+
11
+ it("strips mention name and key for normal mentions", () => {
12
+ const mentions: Mentions = [{ key: "@_bot_1", name: "Bot", id: { open_id: "ou_bot" } }];
13
+ expect(stripBotMention("@Bot hello @_bot_1", mentions)).toBe("hello");
14
+ });
15
+
16
+ it("treats mention.name regex metacharacters as literal text", () => {
17
+ const mentions: Mentions = [{ key: "@_bot_1", name: ".*", id: { open_id: "ou_bot" } }];
18
+ expect(stripBotMention("@NotBot hello", mentions)).toBe("@NotBot hello");
19
+ });
20
+
21
+ it("treats mention.key regex metacharacters as literal text", () => {
22
+ const mentions: Mentions = [{ key: ".*", name: "Bot", id: { open_id: "ou_bot" } }];
23
+ expect(stripBotMention("hello world", mentions)).toBe("hello world");
24
+ });
25
+
26
+ it("trims once after all mention replacements", () => {
27
+ const mentions: Mentions = [{ key: "@_bot_1", name: "Bot", id: { open_id: "ou_bot" } }];
28
+ expect(stripBotMention(" @_bot_1 hello ", mentions)).toBe("hello");
29
+ });
30
+
31
+ it("strips multiple mentions in one pass", () => {
32
+ const mentions: Mentions = [
33
+ { key: "@_bot_1", name: "Bot One", id: { open_id: "ou_bot_1" } },
34
+ { key: "@_bot_2", name: "Bot Two", id: { open_id: "ou_bot_2" } },
35
+ ];
36
+ expect(stripBotMention("@Bot One @_bot_1 hi @Bot Two @_bot_2", mentions)).toBe("hi");
37
+ });
38
+ });
package/src/bot.ts CHANGED
@@ -11,8 +11,14 @@ import { resolveFeishuAccount } from "./accounts.js";
11
11
  import { createFeishuClient } from "./client.js";
12
12
  import { tryRecordMessage } from "./dedup.js";
13
13
  import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
14
- import { downloadImageFeishu, downloadMessageResourceFeishu } from "./media.js";
15
- import { extractMentionTargets, extractMessageBody, isMentionForwardRequest } from "./mention.js";
14
+ import { normalizeFeishuExternalKey } from "./external-keys.js";
15
+ import { downloadMessageResourceFeishu } from "./media.js";
16
+ import {
17
+ escapeRegExp,
18
+ extractMentionTargets,
19
+ extractMessageBody,
20
+ isMentionForwardRequest,
21
+ } from "./mention.js";
16
22
  import {
17
23
  resolveFeishuGroupConfig,
18
24
  resolveFeishuReplyPolicy,
@@ -198,17 +204,17 @@ function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boole
198
204
  return false;
199
205
  }
200
206
 
201
- function stripBotMention(
207
+ export function stripBotMention(
202
208
  text: string,
203
209
  mentions?: FeishuMessageEvent["message"]["mentions"],
204
210
  ): string {
205
211
  if (!mentions || mentions.length === 0) return text;
206
212
  let result = text;
207
213
  for (const mention of mentions) {
208
- result = result.replace(new RegExp(`@${mention.name}\\s*`, "g"), "").trim();
209
- result = result.replace(new RegExp(mention.key, "g"), "").trim();
214
+ result = result.replace(new RegExp(`@${escapeRegExp(mention.name)}\\s*`, "g"), "");
215
+ result = result.replace(new RegExp(escapeRegExp(mention.key), "g"), "");
210
216
  }
211
- return result;
217
+ return result.trim();
212
218
  }
213
219
 
214
220
  /**
@@ -224,18 +230,20 @@ function parseMediaKeys(
224
230
  } {
225
231
  try {
226
232
  const parsed = JSON.parse(content);
233
+ const imageKey = normalizeFeishuExternalKey(parsed.image_key);
234
+ const fileKey = normalizeFeishuExternalKey(parsed.file_key);
227
235
  switch (messageType) {
228
236
  case "image":
229
- return { imageKey: parsed.image_key };
237
+ return { imageKey };
230
238
  case "file":
231
- return { fileKey: parsed.file_key, fileName: parsed.file_name };
239
+ return { fileKey, fileName: parsed.file_name };
232
240
  case "audio":
233
- return { fileKey: parsed.file_key };
241
+ return { fileKey };
234
242
  case "video":
235
243
  // Video has both file_key (video) and image_key (thumbnail)
236
- return { fileKey: parsed.file_key, imageKey: parsed.image_key };
244
+ return { fileKey, imageKey };
237
245
  case "sticker":
238
- return { fileKey: parsed.file_key };
246
+ return { fileKey };
239
247
  default:
240
248
  return {};
241
249
  }
@@ -277,7 +285,10 @@ function parsePostContent(content: string): {
277
285
  }
278
286
  } else if (element.tag === "img" && element.image_key) {
279
287
  // Embedded image
280
- imageKeys.push(element.image_key);
288
+ const imageKey = normalizeFeishuExternalKey(element.image_key);
289
+ if (imageKey) {
290
+ imageKeys.push(imageKey);
291
+ }
281
292
  }
282
293
  }
283
294
  textContent += "\n";
package/src/channel.ts CHANGED
@@ -89,6 +89,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
89
89
  },
90
90
  connectionMode: { type: "string", enum: ["websocket", "webhook"] },
91
91
  webhookPath: { type: "string" },
92
+ webhookHost: { type: "string" },
92
93
  webhookPort: { type: "integer", minimum: 1 },
93
94
  dmPolicy: { type: "string", enum: ["open", "pairing", "allowlist"] },
94
95
  allowFrom: { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } },
@@ -118,6 +119,9 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
118
119
  verificationToken: { type: "string" },
119
120
  domain: { type: "string", enum: ["feishu", "lark"] },
120
121
  connectionMode: { type: "string", enum: ["websocket", "webhook"] },
122
+ webhookHost: { type: "string" },
123
+ webhookPath: { type: "string" },
124
+ webhookPort: { type: "integer", minimum: 1 },
121
125
  },
122
126
  },
123
127
  },
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { FeishuConfigSchema } from "./config-schema.js";
3
+
4
+ describe("FeishuConfigSchema webhook validation", () => {
5
+ it("rejects top-level webhook mode without verificationToken", () => {
6
+ const result = FeishuConfigSchema.safeParse({
7
+ connectionMode: "webhook",
8
+ appId: "cli_top",
9
+ appSecret: "secret_top",
10
+ });
11
+
12
+ expect(result.success).toBe(false);
13
+ if (!result.success) {
14
+ expect(
15
+ result.error.issues.some((issue) => issue.path.join(".") === "verificationToken"),
16
+ ).toBe(true);
17
+ }
18
+ });
19
+
20
+ it("accepts top-level webhook mode with verificationToken", () => {
21
+ const result = FeishuConfigSchema.safeParse({
22
+ connectionMode: "webhook",
23
+ verificationToken: "token_top",
24
+ appId: "cli_top",
25
+ appSecret: "secret_top",
26
+ });
27
+
28
+ expect(result.success).toBe(true);
29
+ });
30
+
31
+ it("rejects account webhook mode without verificationToken", () => {
32
+ const result = FeishuConfigSchema.safeParse({
33
+ accounts: {
34
+ main: {
35
+ connectionMode: "webhook",
36
+ appId: "cli_main",
37
+ appSecret: "secret_main",
38
+ },
39
+ },
40
+ });
41
+
42
+ expect(result.success).toBe(false);
43
+ if (!result.success) {
44
+ expect(
45
+ result.error.issues.some(
46
+ (issue) => issue.path.join(".") === "accounts.main.verificationToken",
47
+ ),
48
+ ).toBe(true);
49
+ }
50
+ });
51
+
52
+ it("accepts account webhook mode inheriting top-level verificationToken", () => {
53
+ const result = FeishuConfigSchema.safeParse({
54
+ verificationToken: "token_top",
55
+ accounts: {
56
+ main: {
57
+ connectionMode: "webhook",
58
+ appId: "cli_main",
59
+ appSecret: "secret_main",
60
+ },
61
+ },
62
+ });
63
+
64
+ expect(result.success).toBe(true);
65
+ });
66
+ });
@@ -127,6 +127,7 @@ export const FeishuAccountConfigSchema = z
127
127
  domain: FeishuDomainSchema.optional(),
128
128
  connectionMode: FeishuConnectionModeSchema.optional(),
129
129
  webhookPath: z.string().optional(),
130
+ webhookHost: z.string().optional(),
130
131
  webhookPort: z.number().int().positive().optional(),
131
132
  capabilities: z.array(z.string()).optional(),
132
133
  markdown: MarkdownConfigSchema,
@@ -162,6 +163,7 @@ export const FeishuConfigSchema = z
162
163
  domain: FeishuDomainSchema.optional().default("feishu"),
163
164
  connectionMode: FeishuConnectionModeSchema.optional().default("websocket"),
164
165
  webhookPath: z.string().optional().default("/feishu/events"),
166
+ webhookHost: z.string().optional(),
165
167
  webhookPort: z.number().int().positive().optional(),
166
168
  capabilities: z.array(z.string()).optional(),
167
169
  markdown: MarkdownConfigSchema,
@@ -191,6 +193,38 @@ export const FeishuConfigSchema = z
191
193
  })
192
194
  .strict()
193
195
  .superRefine((value, ctx) => {
196
+ const defaultConnectionMode = value.connectionMode ?? "websocket";
197
+ const defaultVerificationToken = value.verificationToken?.trim();
198
+ if (defaultConnectionMode === "webhook" && !defaultVerificationToken) {
199
+ ctx.addIssue({
200
+ code: z.ZodIssueCode.custom,
201
+ path: ["verificationToken"],
202
+ message:
203
+ 'channels.feishu.connectionMode="webhook" requires channels.feishu.verificationToken',
204
+ });
205
+ }
206
+
207
+ for (const [accountId, account] of Object.entries(value.accounts ?? {})) {
208
+ if (!account) {
209
+ continue;
210
+ }
211
+ const accountConnectionMode = account.connectionMode ?? defaultConnectionMode;
212
+ if (accountConnectionMode !== "webhook") {
213
+ continue;
214
+ }
215
+ const accountVerificationToken =
216
+ account.verificationToken?.trim() || defaultVerificationToken;
217
+ if (!accountVerificationToken) {
218
+ ctx.addIssue({
219
+ code: z.ZodIssueCode.custom,
220
+ path: ["accounts", accountId, "verificationToken"],
221
+ message:
222
+ `channels.feishu.accounts.${accountId}.connectionMode="webhook" requires ` +
223
+ "a verificationToken (account-level or top-level)",
224
+ });
225
+ }
226
+ }
227
+
194
228
  if (value.dmPolicy === "open") {
195
229
  const allowFrom = value.allowFrom ?? [];
196
230
  const hasWildcard = allowFrom.some((entry) => String(entry).trim() === "*");
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { normalizeFeishuExternalKey } from "./external-keys.js";
3
+
4
+ describe("normalizeFeishuExternalKey", () => {
5
+ it("accepts a normal feishu key and trims surrounding spaces", () => {
6
+ expect(normalizeFeishuExternalKey(" img_v3_01abcDEF123 ")).toBe("img_v3_01abcDEF123");
7
+ });
8
+
9
+ it("rejects traversal and path separator patterns", () => {
10
+ expect(normalizeFeishuExternalKey("../etc/passwd")).toBeUndefined();
11
+ expect(normalizeFeishuExternalKey("a/../../b")).toBeUndefined();
12
+ expect(normalizeFeishuExternalKey("a\\..\\b")).toBeUndefined();
13
+ });
14
+
15
+ it("rejects empty, non-string, and control-char values", () => {
16
+ expect(normalizeFeishuExternalKey(" ")).toBeUndefined();
17
+ expect(normalizeFeishuExternalKey(123)).toBeUndefined();
18
+ expect(normalizeFeishuExternalKey("abc\u0000def")).toBeUndefined();
19
+ });
20
+ });
@@ -0,0 +1,19 @@
1
+ const CONTROL_CHARS_RE = /[\u0000-\u001f\u007f]/;
2
+ const MAX_EXTERNAL_KEY_LENGTH = 512;
3
+
4
+ export function normalizeFeishuExternalKey(value: unknown): string | undefined {
5
+ if (typeof value !== "string") {
6
+ return undefined;
7
+ }
8
+ const normalized = value.trim();
9
+ if (!normalized || normalized.length > MAX_EXTERNAL_KEY_LENGTH) {
10
+ return undefined;
11
+ }
12
+ if (CONTROL_CHARS_RE.test(normalized)) {
13
+ return undefined;
14
+ }
15
+ if (normalized.includes("/") || normalized.includes("\\") || normalized.includes("..")) {
16
+ return undefined;
17
+ }
18
+ return normalized;
19
+ }
package/src/media.test.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
1
4
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
5
 
3
6
  const createFeishuClientMock = vi.hoisted(() => vi.fn());
@@ -7,7 +10,9 @@ const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
7
10
  const loadWebMediaMock = vi.hoisted(() => vi.fn());
8
11
 
9
12
  const fileCreateMock = vi.hoisted(() => vi.fn());
13
+ const imageGetMock = vi.hoisted(() => vi.fn());
10
14
  const messageCreateMock = vi.hoisted(() => vi.fn());
15
+ const messageResourceGetMock = vi.hoisted(() => vi.fn());
11
16
  const messageReplyMock = vi.hoisted(() => vi.fn());
12
17
 
13
18
  vi.mock("./client.js", () => ({
@@ -31,7 +36,7 @@ vi.mock("./runtime.js", () => ({
31
36
  }),
32
37
  }));
33
38
 
34
- import { sendMediaFeishu } from "./media.js";
39
+ import { downloadImageFeishu, downloadMessageResourceFeishu, sendMediaFeishu } from "./media.js";
35
40
 
36
41
  describe("sendMediaFeishu msg_type routing", () => {
37
42
  beforeEach(() => {
@@ -54,10 +59,16 @@ describe("sendMediaFeishu msg_type routing", () => {
54
59
  file: {
55
60
  create: fileCreateMock,
56
61
  },
62
+ image: {
63
+ get: imageGetMock,
64
+ },
57
65
  message: {
58
66
  create: messageCreateMock,
59
67
  reply: messageReplyMock,
60
68
  },
69
+ messageResource: {
70
+ get: messageResourceGetMock,
71
+ },
61
72
  },
62
73
  });
63
74
 
@@ -82,6 +93,9 @@ describe("sendMediaFeishu msg_type routing", () => {
82
93
  kind: "audio",
83
94
  contentType: "audio/ogg",
84
95
  });
96
+
97
+ imageGetMock.mockResolvedValue(Buffer.from("image-bytes"));
98
+ messageResourceGetMock.mockResolvedValue(Buffer.from("resource-bytes"));
85
99
  });
86
100
 
87
101
  it("uses msg_type=media for mp4", async () => {
@@ -184,4 +198,84 @@ describe("sendMediaFeishu msg_type routing", () => {
184
198
  expect(messageCreateMock).not.toHaveBeenCalled();
185
199
  expect(messageReplyMock).not.toHaveBeenCalled();
186
200
  });
201
+
202
+ it("uses isolated temp paths for image downloads", async () => {
203
+ const imageKey = "img_v3_01abc123";
204
+ let capturedPath: string | undefined;
205
+
206
+ imageGetMock.mockResolvedValueOnce({
207
+ writeFile: async (tmpPath: string) => {
208
+ capturedPath = tmpPath;
209
+ await fs.writeFile(tmpPath, Buffer.from("image-data"));
210
+ },
211
+ });
212
+
213
+ const result = await downloadImageFeishu({
214
+ cfg: {} as any,
215
+ imageKey,
216
+ });
217
+
218
+ expect(result.buffer).toEqual(Buffer.from("image-data"));
219
+ expect(capturedPath).toBeDefined();
220
+ expect(capturedPath).not.toContain(imageKey);
221
+ expect(capturedPath).not.toContain("..");
222
+
223
+ const tmpRoot = path.resolve(os.tmpdir());
224
+ const resolved = path.resolve(capturedPath as string);
225
+ const rel = path.relative(tmpRoot, resolved);
226
+ expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
227
+ });
228
+
229
+ it("uses isolated temp paths for message resource downloads", async () => {
230
+ const fileKey = "file_v3_01abc123";
231
+ let capturedPath: string | undefined;
232
+
233
+ messageResourceGetMock.mockResolvedValueOnce({
234
+ writeFile: async (tmpPath: string) => {
235
+ capturedPath = tmpPath;
236
+ await fs.writeFile(tmpPath, Buffer.from("resource-data"));
237
+ },
238
+ });
239
+
240
+ const result = await downloadMessageResourceFeishu({
241
+ cfg: {} as any,
242
+ messageId: "om_123",
243
+ fileKey,
244
+ type: "image",
245
+ });
246
+
247
+ expect(result.buffer).toEqual(Buffer.from("resource-data"));
248
+ expect(capturedPath).toBeDefined();
249
+ expect(capturedPath).not.toContain(fileKey);
250
+ expect(capturedPath).not.toContain("..");
251
+
252
+ const tmpRoot = path.resolve(os.tmpdir());
253
+ const resolved = path.resolve(capturedPath as string);
254
+ const rel = path.relative(tmpRoot, resolved);
255
+ expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
256
+ });
257
+
258
+ it("rejects invalid image keys before calling feishu api", async () => {
259
+ await expect(
260
+ downloadImageFeishu({
261
+ cfg: {} as any,
262
+ imageKey: "a/../../bad",
263
+ }),
264
+ ).rejects.toThrow("invalid image_key");
265
+
266
+ expect(imageGetMock).not.toHaveBeenCalled();
267
+ });
268
+
269
+ it("rejects invalid file keys before calling feishu api", async () => {
270
+ await expect(
271
+ downloadMessageResourceFeishu({
272
+ cfg: {} as any,
273
+ messageId: "om_123",
274
+ fileKey: "x/../../bad",
275
+ type: "file",
276
+ }),
277
+ ).rejects.toThrow("invalid file_key");
278
+
279
+ expect(messageResourceGetMock).not.toHaveBeenCalled();
280
+ });
187
281
  });
package/src/media.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import fs from "fs";
2
- import os from "os";
3
2
  import path from "path";
4
3
  import { Readable } from "stream";
5
- import type { ClawdbotConfig } from "openclaw/plugin-sdk";
4
+ import { withTempDownloadPath, type ClawdbotConfig } from "openclaw/plugin-sdk";
6
5
  import { resolveFeishuAccount } from "./accounts.js";
7
6
  import { createFeishuClient } from "./client.js";
7
+ import { normalizeFeishuExternalKey } from "./external-keys.js";
8
8
  import { getFeishuRuntime } from "./runtime.js";
9
9
  import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
10
10
  import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
@@ -22,7 +22,7 @@ export type DownloadMessageResourceResult = {
22
22
 
23
23
  async function readFeishuResponseBuffer(params: {
24
24
  response: unknown;
25
- tmpPath: string;
25
+ tmpDirPrefix: string;
26
26
  errorPrefix: string;
27
27
  }): Promise<Buffer> {
28
28
  const { response } = params;
@@ -53,10 +53,10 @@ async function readFeishuResponseBuffer(params: {
53
53
  return Buffer.concat(chunks);
54
54
  }
55
55
  if (typeof responseAny.writeFile === "function") {
56
- await responseAny.writeFile(params.tmpPath);
57
- const buffer = await fs.promises.readFile(params.tmpPath);
58
- await fs.promises.unlink(params.tmpPath).catch(() => {});
59
- return buffer;
56
+ return await withTempDownloadPath({ prefix: params.tmpDirPrefix }, async (tmpPath) => {
57
+ await responseAny.writeFile(tmpPath);
58
+ return await fs.promises.readFile(tmpPath);
59
+ });
60
60
  }
61
61
  if (typeof responseAny[Symbol.asyncIterator] === "function") {
62
62
  const chunks: Buffer[] = [];
@@ -88,6 +88,10 @@ export async function downloadImageFeishu(params: {
88
88
  accountId?: string;
89
89
  }): Promise<DownloadImageResult> {
90
90
  const { cfg, imageKey, accountId } = params;
91
+ const normalizedImageKey = normalizeFeishuExternalKey(imageKey);
92
+ if (!normalizedImageKey) {
93
+ throw new Error("Feishu image download failed: invalid image_key");
94
+ }
91
95
  const account = resolveFeishuAccount({ cfg, accountId });
92
96
  if (!account.configured) {
93
97
  throw new Error(`Feishu account "${account.accountId}" not configured`);
@@ -96,13 +100,12 @@ export async function downloadImageFeishu(params: {
96
100
  const client = createFeishuClient(account);
97
101
 
98
102
  const response = await client.im.image.get({
99
- path: { image_key: imageKey },
103
+ path: { image_key: normalizedImageKey },
100
104
  });
101
105
 
102
- const tmpPath = path.join(os.tmpdir(), `feishu_img_${Date.now()}_${imageKey}`);
103
106
  const buffer = await readFeishuResponseBuffer({
104
107
  response,
105
- tmpPath,
108
+ tmpDirPrefix: "openclaw-feishu-img-",
106
109
  errorPrefix: "Feishu image download failed",
107
110
  });
108
111
  return { buffer };
@@ -120,6 +123,10 @@ export async function downloadMessageResourceFeishu(params: {
120
123
  accountId?: string;
121
124
  }): Promise<DownloadMessageResourceResult> {
122
125
  const { cfg, messageId, fileKey, type, accountId } = params;
126
+ const normalizedFileKey = normalizeFeishuExternalKey(fileKey);
127
+ if (!normalizedFileKey) {
128
+ throw new Error("Feishu message resource download failed: invalid file_key");
129
+ }
123
130
  const account = resolveFeishuAccount({ cfg, accountId });
124
131
  if (!account.configured) {
125
132
  throw new Error(`Feishu account "${account.accountId}" not configured`);
@@ -128,14 +135,13 @@ export async function downloadMessageResourceFeishu(params: {
128
135
  const client = createFeishuClient(account);
129
136
 
130
137
  const response = await client.im.messageResource.get({
131
- path: { message_id: messageId, file_key: fileKey },
138
+ path: { message_id: messageId, file_key: normalizedFileKey },
132
139
  params: { type },
133
140
  });
134
141
 
135
- const tmpPath = path.join(os.tmpdir(), `feishu_${Date.now()}_${fileKey}`);
136
142
  const buffer = await readFeishuResponseBuffer({
137
143
  response,
138
- tmpPath,
144
+ tmpDirPrefix: "openclaw-feishu-resource-",
139
145
  errorPrefix: "Feishu message resource download failed",
140
146
  });
141
147
  return { buffer };
package/src/mention.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  import type { FeishuMessageEvent } from "./bot.js";
2
2
 
3
+ /**
4
+ * Escape regex metacharacters so user-controlled mention fields are treated literally.
5
+ */
6
+ export function escapeRegExp(input: string): string {
7
+ return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
8
+ }
9
+
3
10
  /**
4
11
  * Mention target user info
5
12
  */
@@ -67,7 +74,7 @@ export function extractMessageBody(text: string, allMentionKeys: string[]): stri
67
74
 
68
75
  // Remove all @ placeholders
69
76
  for (const key of allMentionKeys) {
70
- result = result.replace(new RegExp(key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), "");
77
+ result = result.replace(new RegExp(escapeRegExp(key), "g"), "");
71
78
  }
72
79
 
73
80
  return result.replace(/\s+/g, " ").trim();
package/src/monitor.ts CHANGED
@@ -25,6 +25,52 @@ const httpServers = new Map<string, http.Server>();
25
25
  const botOpenIds = new Map<string, string>();
26
26
  const FEISHU_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
27
27
  const FEISHU_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
28
+ const FEISHU_WEBHOOK_RATE_LIMIT_WINDOW_MS = 60_000;
29
+ const FEISHU_WEBHOOK_RATE_LIMIT_MAX_REQUESTS = 120;
30
+ const FEISHU_WEBHOOK_COUNTER_LOG_EVERY = 25;
31
+ const feishuWebhookRateLimits = new Map<string, { count: number; windowStartMs: number }>();
32
+ const feishuWebhookStatusCounters = new Map<string, number>();
33
+
34
+ function isJsonContentType(value: string | string[] | undefined): boolean {
35
+ const first = Array.isArray(value) ? value[0] : value;
36
+ if (!first) {
37
+ return false;
38
+ }
39
+ const mediaType = first.split(";", 1)[0]?.trim().toLowerCase();
40
+ return mediaType === "application/json" || Boolean(mediaType?.endsWith("+json"));
41
+ }
42
+
43
+ function isWebhookRateLimited(key: string, nowMs: number): boolean {
44
+ const state = feishuWebhookRateLimits.get(key);
45
+ if (!state || nowMs - state.windowStartMs >= FEISHU_WEBHOOK_RATE_LIMIT_WINDOW_MS) {
46
+ feishuWebhookRateLimits.set(key, { count: 1, windowStartMs: nowMs });
47
+ return false;
48
+ }
49
+
50
+ state.count += 1;
51
+ if (state.count > FEISHU_WEBHOOK_RATE_LIMIT_MAX_REQUESTS) {
52
+ return true;
53
+ }
54
+ return false;
55
+ }
56
+
57
+ function recordWebhookStatus(
58
+ runtime: RuntimeEnv | undefined,
59
+ accountId: string,
60
+ path: string,
61
+ statusCode: number,
62
+ ): void {
63
+ if (![400, 401, 408, 413, 415, 429].includes(statusCode)) {
64
+ return;
65
+ }
66
+ const key = `${accountId}:${path}:${statusCode}`;
67
+ const next = (feishuWebhookStatusCounters.get(key) ?? 0) + 1;
68
+ feishuWebhookStatusCounters.set(key, next);
69
+ if (next === 1 || next % FEISHU_WEBHOOK_COUNTER_LOG_EVERY === 0) {
70
+ const log = runtime?.log ?? console.log;
71
+ log(`feishu[${accountId}]: webhook anomaly path=${path} status=${statusCode} count=${next}`);
72
+ }
73
+ }
28
74
 
29
75
  async function fetchBotOpenId(account: ResolvedFeishuAccount): Promise<string | undefined> {
30
76
  try {
@@ -120,6 +166,9 @@ async function monitorSingleAccount(params: MonitorAccountParams): Promise<void>
120
166
  log(`feishu[${accountId}]: bot open_id resolved: ${botOpenId ?? "unknown"}`);
121
167
 
122
168
  const connectionMode = account.config.connectionMode ?? "websocket";
169
+ if (connectionMode === "webhook" && !account.verificationToken?.trim()) {
170
+ throw new Error(`Feishu account "${accountId}" webhook mode requires verificationToken`);
171
+ }
123
172
  const eventDispatcher = createEventDispatcher(account);
124
173
  const chatHistories = new Map<string, HistoryEntry[]>();
125
174
 
@@ -200,12 +249,30 @@ async function monitorWebhook({
200
249
 
201
250
  const port = account.config.webhookPort ?? 3000;
202
251
  const path = account.config.webhookPath ?? "/feishu/events";
252
+ const host = account.config.webhookHost ?? "127.0.0.1";
203
253
 
204
- log(`feishu[${accountId}]: starting Webhook server on port ${port}, path ${path}...`);
254
+ log(`feishu[${accountId}]: starting Webhook server on ${host}:${port}, path ${path}...`);
205
255
 
206
256
  const server = http.createServer();
207
257
  const webhookHandler = Lark.adaptDefault(path, eventDispatcher, { autoChallenge: true });
208
258
  server.on("request", (req, res) => {
259
+ res.on("finish", () => {
260
+ recordWebhookStatus(runtime, accountId, path, res.statusCode);
261
+ });
262
+
263
+ const rateLimitKey = `${accountId}:${path}:${req.socket.remoteAddress ?? "unknown"}`;
264
+ if (isWebhookRateLimited(rateLimitKey, Date.now())) {
265
+ res.statusCode = 429;
266
+ res.end("Too Many Requests");
267
+ return;
268
+ }
269
+
270
+ if (req.method === "POST" && !isJsonContentType(req.headers["content-type"])) {
271
+ res.statusCode = 415;
272
+ res.end("Unsupported Media Type");
273
+ return;
274
+ }
275
+
209
276
  const guard = installRequestBodyLimitGuard(req, res, {
210
277
  maxBytes: FEISHU_WEBHOOK_MAX_BODY_BYTES,
211
278
  timeoutMs: FEISHU_WEBHOOK_BODY_TIMEOUT_MS,
@@ -247,8 +314,8 @@ async function monitorWebhook({
247
314
 
248
315
  abortSignal?.addEventListener("abort", handleAbort, { once: true });
249
316
 
250
- server.listen(port, () => {
251
- log(`feishu[${accountId}]: Webhook server listening on port ${port}`);
317
+ server.listen(port, host, () => {
318
+ log(`feishu[${accountId}]: Webhook server listening on ${host}:${port}`);
252
319
  });
253
320
 
254
321
  server.on("error", (err) => {
@@ -0,0 +1,174 @@
1
+ import { createServer } from "node:http";
2
+ import type { AddressInfo } from "node:net";
3
+ import type { ClawdbotConfig } from "openclaw/plugin-sdk";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+
6
+ const probeFeishuMock = vi.hoisted(() => vi.fn());
7
+
8
+ vi.mock("@larksuiteoapi/node-sdk", () => ({
9
+ adaptDefault: vi.fn(
10
+ () => (_req: unknown, res: { statusCode?: number; end: (s: string) => void }) => {
11
+ res.statusCode = 200;
12
+ res.end("ok");
13
+ },
14
+ ),
15
+ }));
16
+
17
+ vi.mock("./probe.js", () => ({
18
+ probeFeishu: probeFeishuMock,
19
+ }));
20
+
21
+ vi.mock("./client.js", () => ({
22
+ createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })),
23
+ createEventDispatcher: vi.fn(() => ({ register: vi.fn() })),
24
+ }));
25
+
26
+ import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js";
27
+
28
+ async function getFreePort(): Promise<number> {
29
+ const server = createServer();
30
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
31
+ const address = server.address() as AddressInfo | null;
32
+ if (!address) {
33
+ throw new Error("missing server address");
34
+ }
35
+ await new Promise<void>((resolve) => server.close(() => resolve()));
36
+ return address.port;
37
+ }
38
+
39
+ async function waitUntilServerReady(url: string): Promise<void> {
40
+ for (let i = 0; i < 50; i += 1) {
41
+ try {
42
+ const response = await fetch(url, { method: "GET" });
43
+ if (response.status >= 200 && response.status < 500) {
44
+ return;
45
+ }
46
+ } catch {
47
+ // retry
48
+ }
49
+ await new Promise((resolve) => setTimeout(resolve, 20));
50
+ }
51
+ throw new Error(`server did not start: ${url}`);
52
+ }
53
+
54
+ function buildConfig(params: {
55
+ accountId: string;
56
+ path: string;
57
+ port: number;
58
+ verificationToken?: string;
59
+ }): ClawdbotConfig {
60
+ return {
61
+ channels: {
62
+ feishu: {
63
+ enabled: true,
64
+ accounts: {
65
+ [params.accountId]: {
66
+ enabled: true,
67
+ appId: "cli_test",
68
+ appSecret: "secret_test",
69
+ connectionMode: "webhook",
70
+ webhookHost: "127.0.0.1",
71
+ webhookPort: params.port,
72
+ webhookPath: params.path,
73
+ verificationToken: params.verificationToken,
74
+ },
75
+ },
76
+ },
77
+ },
78
+ } as ClawdbotConfig;
79
+ }
80
+
81
+ afterEach(() => {
82
+ stopFeishuMonitor();
83
+ });
84
+
85
+ describe("Feishu webhook security hardening", () => {
86
+ it("rejects webhook mode without verificationToken", async () => {
87
+ probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
88
+
89
+ const cfg = buildConfig({
90
+ accountId: "missing-token",
91
+ path: "/hook-missing-token",
92
+ port: await getFreePort(),
93
+ });
94
+
95
+ await expect(monitorFeishuProvider({ config: cfg })).rejects.toThrow(
96
+ /requires verificationToken/i,
97
+ );
98
+ });
99
+
100
+ it("returns 415 for POST requests without json content type", async () => {
101
+ probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
102
+ const port = await getFreePort();
103
+ const path = "/hook-content-type";
104
+ const cfg = buildConfig({
105
+ accountId: "content-type",
106
+ path,
107
+ port,
108
+ verificationToken: "verify_token",
109
+ });
110
+
111
+ const abortController = new AbortController();
112
+ const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
113
+ const monitorPromise = monitorFeishuProvider({
114
+ config: cfg,
115
+ runtime,
116
+ abortSignal: abortController.signal,
117
+ });
118
+
119
+ await waitUntilServerReady(`http://127.0.0.1:${port}${path}`);
120
+
121
+ const response = await fetch(`http://127.0.0.1:${port}${path}`, {
122
+ method: "POST",
123
+ headers: { "content-type": "text/plain" },
124
+ body: "{}",
125
+ });
126
+
127
+ expect(response.status).toBe(415);
128
+ expect(await response.text()).toBe("Unsupported Media Type");
129
+
130
+ abortController.abort();
131
+ await monitorPromise;
132
+ });
133
+
134
+ it("rate limits webhook burst traffic with 429", async () => {
135
+ probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
136
+ const port = await getFreePort();
137
+ const path = "/hook-rate-limit";
138
+ const cfg = buildConfig({
139
+ accountId: "rate-limit",
140
+ path,
141
+ port,
142
+ verificationToken: "verify_token",
143
+ });
144
+
145
+ const abortController = new AbortController();
146
+ const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
147
+ const monitorPromise = monitorFeishuProvider({
148
+ config: cfg,
149
+ runtime,
150
+ abortSignal: abortController.signal,
151
+ });
152
+
153
+ await waitUntilServerReady(`http://127.0.0.1:${port}${path}`);
154
+
155
+ let saw429 = false;
156
+ for (let i = 0; i < 130; i += 1) {
157
+ const response = await fetch(`http://127.0.0.1:${port}${path}`, {
158
+ method: "POST",
159
+ headers: { "content-type": "text/plain" },
160
+ body: "{}",
161
+ });
162
+ if (response.status === 429) {
163
+ saw429 = true;
164
+ expect(await response.text()).toBe("Too Many Requests");
165
+ break;
166
+ }
167
+ }
168
+
169
+ expect(saw429).toBe(true);
170
+
171
+ abortController.abort();
172
+ await monitorPromise;
173
+ });
174
+ });