diff --git a/CHANGELOG.md b/CHANGELOG.md index bde1850b7..59641be82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai - Security/browser.request: block persistent browser profile create/delete routes from write-scoped `browser.request` so callers can no longer persist admin-only browser profile changes through the browser control surface. (`GHSA-vmhq-cqm9-6p7q`)(#43800) Thanks @tdjackey and @vincentkoc. - Security/agent: reject public spawned-run lineage fields and keep workspace inheritance on the internal spawned-session path so external `agent` callers can no longer override the gateway workspace boundary. (`GHSA-2rqg-gjgv-84jm`)(#43801) Thanks @tdjackey and @vincentkoc. - Security/exec allowlist: preserve POSIX case sensitivity and keep `?` within a single path segment so exact-looking allowlist patterns no longer overmatch executables across case or directory boundaries. (`GHSA-f8r2-vg7x-gh8m`)(#43798) Thanks @zpbrent and @vincentkoc. +- Security/Feishu reactions: preserve looked-up group chat typing and fail closed on ambiguous reaction context so group authorization and mention gating cannot be bypassed through synthetic `p2p` reactions. (`GHSA-m69h-jm2f-2pv8`)(#44088) Thanks @zpbrent and @vincentkoc. - Security/LINE webhook: require signatures for empty-event POST probes too so unsigned requests no longer confirm webhook reachability with a `200` response. (`GHSA-mhxh-9pjm-w7q5`)(#44090) Thanks @TerminalsandCoffee and @vincentkoc. ### Changes diff --git a/extensions/feishu/src/monitor.account.ts b/extensions/feishu/src/monitor.account.ts index 601f78f08..09f18cbcd 100644 --- a/extensions/feishu/src/monitor.account.ts +++ b/extensions/feishu/src/monitor.account.ts @@ -24,14 +24,14 @@ import { botNames, botOpenIds } from "./monitor.state.js"; import { monitorWebhook, monitorWebSocket } from "./monitor.transport.js"; import { getFeishuRuntime } from "./runtime.js"; import { getMessageFeishu } from "./send.js"; -import type { ResolvedFeishuAccount } from "./types.js"; +import type { FeishuChatType, ResolvedFeishuAccount } from "./types.js"; const FEISHU_REACTION_VERIFY_TIMEOUT_MS = 1_500; export type FeishuReactionCreatedEvent = { message_id: string; chat_id?: string; - chat_type?: "p2p" | "group" | "private"; + chat_type?: string; reaction_type?: { emoji_type?: string }; operator_type?: string; user_id?: { open_id?: string }; @@ -105,10 +105,19 @@ export async function resolveReactionSyntheticEvent( return null; } + const fallbackChatType = reactedMsg.chatType; + const normalizedEventChatType = normalizeFeishuChatType(event.chat_type); + const resolvedChatType = normalizedEventChatType ?? fallbackChatType; + if (!resolvedChatType) { + logger?.( + `feishu[${accountId}]: skipping reaction ${emoji} on ${messageId} without chat type context`, + ); + return null; + } + const syntheticChatIdRaw = event.chat_id ?? reactedMsg.chatId; const syntheticChatId = syntheticChatIdRaw?.trim() ? syntheticChatIdRaw : `p2p:${senderId}`; - const syntheticChatType: "p2p" | "group" | "private" = - event.chat_type === "group" ? "group" : "p2p"; + const syntheticChatType: FeishuChatType = resolvedChatType; return { sender: { sender_id: { open_id: senderId }, @@ -126,6 +135,10 @@ export async function resolveReactionSyntheticEvent( }; } +function normalizeFeishuChatType(value: unknown): FeishuChatType | undefined { + return value === "group" || value === "private" || value === "p2p" ? value : undefined; +} + type RegisterEventHandlersContext = { cfg: ClawdbotConfig; accountId: string; diff --git a/extensions/feishu/src/monitor.reaction.test.ts b/extensions/feishu/src/monitor.reaction.test.ts index 5537af6b2..e17859d05 100644 --- a/extensions/feishu/src/monitor.reaction.test.ts +++ b/extensions/feishu/src/monitor.reaction.test.ts @@ -51,10 +51,11 @@ function makeReactionEvent( }; } -function createFetchedReactionMessage(chatId: string) { +function createFetchedReactionMessage(chatId: string, chatType?: "p2p" | "group" | "private") { return { messageId: "om_msg1", chatId, + chatType, senderOpenId: "ou_bot", content: "hello", contentType: "text", @@ -64,13 +65,15 @@ function createFetchedReactionMessage(chatId: string) { async function resolveReactionWithLookup(params: { event?: FeishuReactionCreatedEvent; lookupChatId: string; + lookupChatType?: "p2p" | "group" | "private"; }) { return await resolveReactionSyntheticEvent({ cfg, accountId: "default", event: params.event ?? makeReactionEvent(), botOpenId: "ou_bot", - fetchMessage: async () => createFetchedReactionMessage(params.lookupChatId), + fetchMessage: async () => + createFetchedReactionMessage(params.lookupChatId, params.lookupChatType), uuid: () => "fixed-uuid", }); } @@ -268,6 +271,7 @@ describe("resolveReactionSyntheticEvent", () => { fetchMessage: async () => ({ messageId: "om_msg1", chatId: "oc_group", + chatType: "group", senderOpenId: "ou_other", senderType: "user", content: "hello", @@ -293,6 +297,7 @@ describe("resolveReactionSyntheticEvent", () => { fetchMessage: async () => ({ messageId: "om_msg1", chatId: "oc_group", + chatType: "group", senderOpenId: "ou_other", senderType: "user", content: "hello", @@ -348,21 +353,43 @@ describe("resolveReactionSyntheticEvent", () => { it("falls back to reacted message chat_id when event chat_id is absent", async () => { const result = await resolveReactionWithLookup({ lookupChatId: "oc_group_from_lookup", + lookupChatType: "group", }); expect(result?.message.chat_id).toBe("oc_group_from_lookup"); - expect(result?.message.chat_type).toBe("p2p"); + expect(result?.message.chat_type).toBe("group"); }); it("falls back to sender p2p chat when lookup returns empty chat_id", async () => { const result = await resolveReactionWithLookup({ lookupChatId: "", + lookupChatType: "p2p", }); expect(result?.message.chat_id).toBe("p2p:ou_user1"); expect(result?.message.chat_type).toBe("p2p"); }); + it("drops reactions without chat context when lookup does not provide chat_type", async () => { + const result = await resolveReactionWithLookup({ + lookupChatId: "oc_group_from_lookup", + }); + + expect(result).toBeNull(); + }); + + it("drops reactions when event chat_type is invalid and lookup cannot recover it", async () => { + const result = await resolveReactionWithLookup({ + event: makeReactionEvent({ + chat_id: "oc_group_from_event", + chat_type: "bogus" as "group", + }), + lookupChatId: "oc_group_from_lookup", + }); + + expect(result).toBeNull(); + }); + it("logs and drops reactions when lookup throws", async () => { const log = vi.fn(); const event = makeReactionEvent(); diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts index 928ef07f9..0f4fd7e77 100644 --- a/extensions/feishu/src/send.ts +++ b/extensions/feishu/src/send.ts @@ -7,7 +7,7 @@ import { parsePostContent } from "./post.js"; import { getFeishuRuntime } from "./runtime.js"; import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js"; import { resolveFeishuSendTarget } from "./send-target.js"; -import type { FeishuSendResult } from "./types.js"; +import type { FeishuChatType, FeishuMessageInfo, FeishuSendResult } from "./types.js"; const WITHDRAWN_REPLY_ERROR_CODES = new Set([230011, 231003]); @@ -74,17 +74,6 @@ async function sendFallbackDirect( return toFeishuSendResult(response, params.receiveId); } -export type FeishuMessageInfo = { - messageId: string; - chatId: string; - senderId?: string; - senderOpenId?: string; - senderType?: string; - content: string; - contentType: string; - createTime?: number; -}; - function parseInteractiveCardContent(parsed: unknown): string { if (!parsed || typeof parsed !== "object") { return "[Interactive Card]"; @@ -184,6 +173,7 @@ export async function getMessageFeishu(params: { items?: Array<{ message_id?: string; chat_id?: string; + chat_type?: FeishuChatType; msg_type?: string; body?: { content?: string }; sender?: { @@ -195,6 +185,7 @@ export async function getMessageFeishu(params: { }>; message_id?: string; chat_id?: string; + chat_type?: FeishuChatType; msg_type?: string; body?: { content?: string }; sender?: { @@ -228,6 +219,10 @@ export async function getMessageFeishu(params: { return { messageId: item.message_id ?? messageId, chatId: item.chat_id ?? "", + chatType: + item.chat_type === "group" || item.chat_type === "private" || item.chat_type === "p2p" + ? item.chat_type + : undefined, senderId: item.sender?.id, senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined, senderType: item.sender?.sender_type, diff --git a/extensions/feishu/src/types.ts b/extensions/feishu/src/types.ts index 2160ae05c..c28398fca 100644 --- a/extensions/feishu/src/types.ts +++ b/extensions/feishu/src/types.ts @@ -60,6 +60,20 @@ export type FeishuSendResult = { chatId: string; }; +export type FeishuChatType = "p2p" | "group" | "private"; + +export type FeishuMessageInfo = { + messageId: string; + chatId: string; + chatType?: FeishuChatType; + senderId?: string; + senderOpenId?: string; + senderType?: string; + content: string; + contentType: string; + createTime?: number; +}; + export type FeishuProbeResult = BaseProbeResult & { appId?: string; botName?: string;