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>
This commit is contained in:
Haitian
2026-02-27 19:49:47 -08:00
committed by GitHub
parent aef5355102
commit 107be4e909
4 changed files with 129 additions and 4 deletions

View File

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

View File

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

View File

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

View File

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