Security: preserve Feishu reaction chat type (#44088)

* Feishu: preserve looked-up chat type

* Feishu: fail closed on ambiguous reaction chats

* Feishu: cover reaction chat type fallback

* Changelog: note Feishu reaction hardening

* Feishu: fail closed without resolved chat type

* Feishu: normalize reaction chat type at runtime
This commit is contained in:
Vincent Koc 2026-03-12 10:53:40 -04:00 committed by GitHub
parent 48cbfdfac0
commit 3e730c0332
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 69 additions and 19 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string> & {
appId?: string;
botName?: string;