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

@@ -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/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/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/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. - 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 ### Changes

View File

@@ -24,14 +24,14 @@ import { botNames, botOpenIds } from "./monitor.state.js";
import { monitorWebhook, monitorWebSocket } from "./monitor.transport.js"; import { monitorWebhook, monitorWebSocket } from "./monitor.transport.js";
import { getFeishuRuntime } from "./runtime.js"; import { getFeishuRuntime } from "./runtime.js";
import { getMessageFeishu } from "./send.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; const FEISHU_REACTION_VERIFY_TIMEOUT_MS = 1_500;
export type FeishuReactionCreatedEvent = { export type FeishuReactionCreatedEvent = {
message_id: string; message_id: string;
chat_id?: string; chat_id?: string;
chat_type?: "p2p" | "group" | "private"; chat_type?: string;
reaction_type?: { emoji_type?: string }; reaction_type?: { emoji_type?: string };
operator_type?: string; operator_type?: string;
user_id?: { open_id?: string }; user_id?: { open_id?: string };
@@ -105,10 +105,19 @@ export async function resolveReactionSyntheticEvent(
return null; 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 syntheticChatIdRaw = event.chat_id ?? reactedMsg.chatId;
const syntheticChatId = syntheticChatIdRaw?.trim() ? syntheticChatIdRaw : `p2p:${senderId}`; const syntheticChatId = syntheticChatIdRaw?.trim() ? syntheticChatIdRaw : `p2p:${senderId}`;
const syntheticChatType: "p2p" | "group" | "private" = const syntheticChatType: FeishuChatType = resolvedChatType;
event.chat_type === "group" ? "group" : "p2p";
return { return {
sender: { sender: {
sender_id: { open_id: senderId }, 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 = { type RegisterEventHandlersContext = {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
accountId: string; accountId: string;

View File

@@ -51,10 +51,11 @@ function makeReactionEvent(
}; };
} }
function createFetchedReactionMessage(chatId: string) { function createFetchedReactionMessage(chatId: string, chatType?: "p2p" | "group" | "private") {
return { return {
messageId: "om_msg1", messageId: "om_msg1",
chatId, chatId,
chatType,
senderOpenId: "ou_bot", senderOpenId: "ou_bot",
content: "hello", content: "hello",
contentType: "text", contentType: "text",
@@ -64,13 +65,15 @@ function createFetchedReactionMessage(chatId: string) {
async function resolveReactionWithLookup(params: { async function resolveReactionWithLookup(params: {
event?: FeishuReactionCreatedEvent; event?: FeishuReactionCreatedEvent;
lookupChatId: string; lookupChatId: string;
lookupChatType?: "p2p" | "group" | "private";
}) { }) {
return await resolveReactionSyntheticEvent({ return await resolveReactionSyntheticEvent({
cfg, cfg,
accountId: "default", accountId: "default",
event: params.event ?? makeReactionEvent(), event: params.event ?? makeReactionEvent(),
botOpenId: "ou_bot", botOpenId: "ou_bot",
fetchMessage: async () => createFetchedReactionMessage(params.lookupChatId), fetchMessage: async () =>
createFetchedReactionMessage(params.lookupChatId, params.lookupChatType),
uuid: () => "fixed-uuid", uuid: () => "fixed-uuid",
}); });
} }
@@ -268,6 +271,7 @@ describe("resolveReactionSyntheticEvent", () => {
fetchMessage: async () => ({ fetchMessage: async () => ({
messageId: "om_msg1", messageId: "om_msg1",
chatId: "oc_group", chatId: "oc_group",
chatType: "group",
senderOpenId: "ou_other", senderOpenId: "ou_other",
senderType: "user", senderType: "user",
content: "hello", content: "hello",
@@ -293,6 +297,7 @@ describe("resolveReactionSyntheticEvent", () => {
fetchMessage: async () => ({ fetchMessage: async () => ({
messageId: "om_msg1", messageId: "om_msg1",
chatId: "oc_group", chatId: "oc_group",
chatType: "group",
senderOpenId: "ou_other", senderOpenId: "ou_other",
senderType: "user", senderType: "user",
content: "hello", content: "hello",
@@ -348,21 +353,43 @@ describe("resolveReactionSyntheticEvent", () => {
it("falls back to reacted message chat_id when event chat_id is absent", async () => { it("falls back to reacted message chat_id when event chat_id is absent", async () => {
const result = await resolveReactionWithLookup({ const result = await resolveReactionWithLookup({
lookupChatId: "oc_group_from_lookup", lookupChatId: "oc_group_from_lookup",
lookupChatType: "group",
}); });
expect(result?.message.chat_id).toBe("oc_group_from_lookup"); 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 () => { it("falls back to sender p2p chat when lookup returns empty chat_id", async () => {
const result = await resolveReactionWithLookup({ const result = await resolveReactionWithLookup({
lookupChatId: "", lookupChatId: "",
lookupChatType: "p2p",
}); });
expect(result?.message.chat_id).toBe("p2p:ou_user1"); expect(result?.message.chat_id).toBe("p2p:ou_user1");
expect(result?.message.chat_type).toBe("p2p"); 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 () => { it("logs and drops reactions when lookup throws", async () => {
const log = vi.fn(); const log = vi.fn();
const event = makeReactionEvent(); const event = makeReactionEvent();

View File

@@ -7,7 +7,7 @@ import { parsePostContent } from "./post.js";
import { getFeishuRuntime } from "./runtime.js"; import { getFeishuRuntime } from "./runtime.js";
import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js"; import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
import { resolveFeishuSendTarget } from "./send-target.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]); const WITHDRAWN_REPLY_ERROR_CODES = new Set([230011, 231003]);
@@ -74,17 +74,6 @@ async function sendFallbackDirect(
return toFeishuSendResult(response, params.receiveId); 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 { function parseInteractiveCardContent(parsed: unknown): string {
if (!parsed || typeof parsed !== "object") { if (!parsed || typeof parsed !== "object") {
return "[Interactive Card]"; return "[Interactive Card]";
@@ -184,6 +173,7 @@ export async function getMessageFeishu(params: {
items?: Array<{ items?: Array<{
message_id?: string; message_id?: string;
chat_id?: string; chat_id?: string;
chat_type?: FeishuChatType;
msg_type?: string; msg_type?: string;
body?: { content?: string }; body?: { content?: string };
sender?: { sender?: {
@@ -195,6 +185,7 @@ export async function getMessageFeishu(params: {
}>; }>;
message_id?: string; message_id?: string;
chat_id?: string; chat_id?: string;
chat_type?: FeishuChatType;
msg_type?: string; msg_type?: string;
body?: { content?: string }; body?: { content?: string };
sender?: { sender?: {
@@ -228,6 +219,10 @@ export async function getMessageFeishu(params: {
return { return {
messageId: item.message_id ?? messageId, messageId: item.message_id ?? messageId,
chatId: item.chat_id ?? "", 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, senderId: item.sender?.id,
senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined, senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined,
senderType: item.sender?.sender_type, senderType: item.sender?.sender_type,

View File

@@ -60,6 +60,20 @@ export type FeishuSendResult = {
chatId: string; 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> & { export type FeishuProbeResult = BaseProbeResult<string> & {
appId?: string; appId?: string;
botName?: string; botName?: string;