From 107be4e90959faa9b1f9553faf78521a358a0a26 Mon Sep 17 00:00:00 2001 From: Haitian Date: Fri, 27 Feb 2026 19:49:47 -0800 Subject: [PATCH] feat(feishu): add global groupSenderAllowFrom for sender-level group access control (openclaw#29174) thanks @1MoreBuild Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: 1MoreBuild <11406106+1MoreBuild@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + extensions/feishu/src/bot.test.ts | 120 +++++++++++++++++++++++++ extensions/feishu/src/bot.ts | 11 ++- extensions/feishu/src/config-schema.ts | 1 + 4 files changed, 129 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf5591eb3af..e529907390d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai - Feishu/Opus media send type: send `.opus` attachments with `msg_type: "audio"` (instead of `"media"`) so Feishu voice messages deliver correctly while `.mp4` remains `msg_type: "media"` and documents remain `msg_type: "file"`. (#28269) Thanks @Glucksberg. - Feishu/Inbound rich-text parsing: preserve `share_chat` payload summaries when available and add explicit parsing for rich-text `code`/`code_block`/`pre` tags so forwarded and code-heavy messages keep useful context in agent input. (#28591) Thanks @kevinWangSheng. - Feishu/Local media sends: propagate `mediaLocalRoots` through Feishu outbound media sending into `loadWebMedia` so local path attachments work with post-CVE local-root enforcement. (#27884) Thanks @joelnishanth. +- Feishu/Group sender allowlist fallback: add global `channels.feishu.groupSenderAllowFrom` sender authorization for group chats, with per-group `groups..allowFrom` precedence and regression coverage for allow/block/precedence behavior. (#29174) Thanks @1MoreBuild. - Security/Feishu webhook ingress: bound unauthenticated webhook rate-limit state with stale-window pruning and a hard key cap to prevent unbounded pre-auth memory growth from rotating source keys. (#26050) Thanks @bmendonca3. - Security/Compaction audit: remove the post-compaction audit injection message. (#28507) Thanks @fuller-stack-dev and @vincentkoc. - Telegram/Reply media context: include replied media files in inbound context when replying to media, defer reply-media downloads to debounce flush, gate reply-media fetch behind DM authorization, and preserve replied media when non-vision sticker fallback runs (including cached-sticker paths). (#28488) Thanks @obviyus. diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index 2d2491d11af..6fbe8225a58 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -403,6 +403,126 @@ describe("handleFeishuMessage command authorization", () => { ); }); + it("allows group sender when global groupSenderAllowFrom includes sender", async () => { + mockShouldComputeCommandAuthorized.mockReturnValue(false); + + const cfg: ClawdbotConfig = { + channels: { + feishu: { + groupPolicy: "open", + groupSenderAllowFrom: ["ou-allowed"], + groups: { + "oc-group": { + requireMention: false, + }, + }, + }, + }, + } as ClawdbotConfig; + + const event: FeishuMessageEvent = { + sender: { + sender_id: { + open_id: "ou-allowed", + }, + }, + message: { + message_id: "msg-global-group-sender-allow", + chat_id: "oc-group", + chat_type: "group", + message_type: "text", + content: JSON.stringify({ text: "hello" }), + }, + }; + + await dispatchMessage({ cfg, event }); + + expect(mockFinalizeInboundContext).toHaveBeenCalledWith( + expect.objectContaining({ + ChatType: "group", + SenderId: "ou-allowed", + }), + ); + expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1); + }); + + it("blocks group sender when global groupSenderAllowFrom excludes sender", async () => { + mockShouldComputeCommandAuthorized.mockReturnValue(false); + + const cfg: ClawdbotConfig = { + channels: { + feishu: { + groupPolicy: "open", + groupSenderAllowFrom: ["ou-allowed"], + groups: { + "oc-group": { + requireMention: false, + }, + }, + }, + }, + } as ClawdbotConfig; + + const event: FeishuMessageEvent = { + sender: { + sender_id: { + open_id: "ou-blocked", + }, + }, + message: { + message_id: "msg-global-group-sender-block", + chat_id: "oc-group", + chat_type: "group", + message_type: "text", + content: JSON.stringify({ text: "hello" }), + }, + }; + + await dispatchMessage({ cfg, event }); + + expect(mockFinalizeInboundContext).not.toHaveBeenCalled(); + expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled(); + }); + + it("prefers per-group allowFrom over global groupSenderAllowFrom", async () => { + mockShouldComputeCommandAuthorized.mockReturnValue(false); + + const cfg: ClawdbotConfig = { + channels: { + feishu: { + groupPolicy: "open", + groupSenderAllowFrom: ["ou-global"], + groups: { + "oc-group": { + allowFrom: ["ou-group-only"], + requireMention: false, + }, + }, + }, + }, + } as ClawdbotConfig; + + const event: FeishuMessageEvent = { + sender: { + sender_id: { + open_id: "ou-global", + }, + }, + message: { + message_id: "msg-per-group-precedence", + chat_id: "oc-group", + chat_type: "group", + message_type: "text", + content: JSON.stringify({ text: "hello" }), + }, + }; + + await dispatchMessage({ cfg, event }); + + expect(mockFinalizeInboundContext).not.toHaveBeenCalled(); + expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled(); + }); + it("uses video file_key (not thumbnail image_key) for inbound video download", async () => { mockShouldComputeCommandAuthorized.mockReturnValue(false); diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 95556aaafc4..a5fd255cfd6 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -812,12 +812,15 @@ export async function handleFeishuMessage(params: { return; } - // Additional sender-level allowlist check if group has specific allowFrom config - const senderAllowFrom = groupConfig?.allowFrom ?? []; - if (senderAllowFrom.length > 0) { + // Sender-level allowlist: per-group allowFrom takes precedence, then global groupSenderAllowFrom + const perGroupSenderAllowFrom = groupConfig?.allowFrom ?? []; + const globalSenderAllowFrom = feishuCfg?.groupSenderAllowFrom ?? []; + const effectiveSenderAllowFrom = + perGroupSenderAllowFrom.length > 0 ? perGroupSenderAllowFrom : globalSenderAllowFrom; + if (effectiveSenderAllowFrom.length > 0) { const senderAllowed = isFeishuGroupAllowed({ groupPolicy: "allowlist", - allowFrom: senderAllowFrom, + allowFrom: effectiveSenderAllowFrom, senderId: ctx.senderOpenId, senderIds: [senderUserId], senderName: ctx.senderName, diff --git a/extensions/feishu/src/config-schema.ts b/extensions/feishu/src/config-schema.ts index 2e85cc93fea..6fd60c179e1 100644 --- a/extensions/feishu/src/config-schema.ts +++ b/extensions/feishu/src/config-schema.ts @@ -146,6 +146,7 @@ const FeishuSharedConfigShape = { allowFrom: z.array(z.union([z.string(), z.number()])).optional(), groupPolicy: GroupPolicySchema.optional(), groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupSenderAllowFrom: 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(),