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:
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user