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
5 changed files with 69 additions and 19 deletions

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;