Moderate severityNVD Advisory· Published Mar 21, 2026· Updated Mar 21, 2026
OpenClaw < 2026.2.25 - Sender Policy Bypass in Slack Reaction and Pin Event Handlers
CVE-2026-32899
Description
OpenClaw versions prior to 2026.2.25 fail to consistently apply sender-policy checks to reaction_* and pin_* non-message events before adding them to system-event context. Attackers can bypass configured DM policies and channel user allowlists to inject unauthorized reaction and pin events from restricted senders.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.2.25 | 2026.2.25 |
Affected products
1Patches
275dfb71e4e8bfix(slack): gate pin/reaction system events by sender auth
5 files changed · +227 −53
CHANGELOG.md+2 −1 modified@@ -22,7 +22,8 @@ Docs: https://docs.openclaw.ai - Models/Auth probes: map permanent auth failover reasons (`auth_permanent`, for example revoked keys) into probe auth status instead of `unknown`, so `openclaw models status --probe` reports actionable auth failures. (#25754) thanks @rrenamed. - Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under `dmPolicy`/`groupPolicy`; reaction notifications now require channel access checks first. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Exec approvals: bind `system.run` approval matching to exact argv identity and preserve argv whitespace in rendered command text, preventing trailing-space executable path swaps from reusing a mismatched approval. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. -- Security/Discord + Slack reactions: enforce DM policy/allowlist authorization before reaction-event system enqueue in direct messages; Discord reaction handling now also honors DM/group-DM enablement and guild `groupPolicy` channel gating to keep reaction ingress aligned with normal message preflight. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. +- Security/Discord reactions: enforce DM policy/allowlist authorization before reaction-event system enqueue in direct messages; Discord reaction handling now also honors DM/group-DM enablement and guild `groupPolicy` channel gating to keep reaction ingress aligned with normal message preflight. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. +- Security/Slack reactions + pins: gate `reaction_*` and `pin_*` system-event enqueue through shared sender authorization so DM `dmPolicy`/`allowFrom` and channel `users` allowlists are enforced consistently for non-message ingress, with regression coverage for denied/allowed sender paths. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Telegram reactions: enforce `dmPolicy`/`allowFrom` and group allowlist authorization on `message_reaction` events before enqueueing reaction system events, preventing unauthorized reaction-triggered input in DMs and groups; ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Slack interactions: enforce channel/DM authorization and modal actor binding (`private_metadata.userId`) before enqueueing `block_action`/`view_submission`/`view_closed` system events, with regression coverage for unauthorized senders and missing/mismatched actor metadata. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy `oauth.json` onboarding path that exposed the PKCE verifier via OAuth `state`; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (`2026.2.25`). Thanks @zdi-disclosures for reporting.
src/slack/monitor/events/pins.test.ts+170 −0 added@@ -0,0 +1,170 @@ +import { describe, expect, it, vi } from "vitest"; +import type { SlackMonitorContext } from "../context.js"; +import { registerSlackPinEvents } from "./pins.js"; + +const enqueueSystemEventMock = vi.fn(); +const readAllowFromStoreMock = vi.fn(); + +vi.mock("../../../infra/system-events.js", () => ({ + enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args), +})); + +vi.mock("../../../pairing/pairing-store.js", () => ({ + readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), +})); + +type SlackPinHandler = (args: { event: Record<string, unknown>; body: unknown }) => Promise<void>; + +function createPinContext(overrides?: { + dmPolicy?: "open" | "pairing" | "allowlist" | "disabled"; + allowFrom?: string[]; + channelType?: "im" | "channel"; + channelUsers?: string[]; +}) { + let addedHandler: SlackPinHandler | null = null; + let removedHandler: SlackPinHandler | null = null; + const channelType = overrides?.channelType ?? "im"; + const app = { + event: vi.fn((name: string, handler: SlackPinHandler) => { + if (name === "pin_added") { + addedHandler = handler; + } else if (name === "pin_removed") { + removedHandler = handler; + } + }), + }; + const ctx = { + app, + runtime: { error: vi.fn() }, + dmEnabled: true, + dmPolicy: overrides?.dmPolicy ?? "open", + defaultRequireMention: true, + channelsConfig: overrides?.channelUsers + ? { + C1: { + users: overrides.channelUsers, + allow: true, + }, + } + : undefined, + groupPolicy: "open", + allowFrom: overrides?.allowFrom ?? [], + allowNameMatching: false, + shouldDropMismatchedSlackEvent: vi.fn().mockReturnValue(false), + isChannelAllowed: vi.fn().mockReturnValue(true), + resolveChannelName: vi.fn().mockResolvedValue({ + name: channelType === "im" ? "direct" : "general", + type: channelType, + }), + resolveUserName: vi.fn().mockResolvedValue({ name: "alice" }), + resolveSlackSystemEventSessionKey: vi.fn().mockReturnValue("agent:main:main"), + } as unknown as SlackMonitorContext; + registerSlackPinEvents({ ctx }); + return { + ctx, + getAddedHandler: () => addedHandler, + getRemovedHandler: () => removedHandler, + }; +} + +function makePinEvent(overrides?: { user?: string; channel?: string }) { + return { + type: "pin_added", + user: overrides?.user ?? "U1", + channel_id: overrides?.channel ?? "D1", + event_ts: "123.456", + item: { + type: "message", + message: { + ts: "123.456", + }, + }, + }; +} + +describe("registerSlackPinEvents", () => { + it("enqueues DM pin system events when dmPolicy is open", async () => { + enqueueSystemEventMock.mockClear(); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + const { getAddedHandler } = createPinContext({ dmPolicy: "open" }); + const addedHandler = getAddedHandler(); + expect(addedHandler).toBeTruthy(); + + await addedHandler!({ + event: makePinEvent(), + body: {}, + }); + + expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1); + }); + + it("blocks DM pin system events when dmPolicy is disabled", async () => { + enqueueSystemEventMock.mockClear(); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + const { getAddedHandler } = createPinContext({ dmPolicy: "disabled" }); + const addedHandler = getAddedHandler(); + expect(addedHandler).toBeTruthy(); + + await addedHandler!({ + event: makePinEvent(), + body: {}, + }); + + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + }); + + it("blocks DM pin system events for unauthorized senders in allowlist mode", async () => { + enqueueSystemEventMock.mockClear(); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + const { getAddedHandler } = createPinContext({ + dmPolicy: "allowlist", + allowFrom: ["U2"], + }); + const addedHandler = getAddedHandler(); + expect(addedHandler).toBeTruthy(); + + await addedHandler!({ + event: makePinEvent({ user: "U1" }), + body: {}, + }); + + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + }); + + it("allows DM pin system events for authorized senders in allowlist mode", async () => { + enqueueSystemEventMock.mockClear(); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + const { getAddedHandler } = createPinContext({ + dmPolicy: "allowlist", + allowFrom: ["U1"], + }); + const addedHandler = getAddedHandler(); + expect(addedHandler).toBeTruthy(); + + await addedHandler!({ + event: makePinEvent({ user: "U1" }), + body: {}, + }); + + expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1); + }); + + it("blocks channel pin events for users outside channel users allowlist", async () => { + enqueueSystemEventMock.mockClear(); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + const { getAddedHandler } = createPinContext({ + dmPolicy: "open", + channelType: "channel", + channelUsers: ["U_OWNER"], + }); + const addedHandler = getAddedHandler(); + expect(addedHandler).toBeTruthy(); + + await addedHandler!({ + event: makePinEvent({ channel: "C1", user: "U_ATTACKER" }), + body: {}, + }); + + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + }); +});
src/slack/monitor/events/pins.ts+13 −11 modified@@ -1,6 +1,7 @@ import type { SlackEventMiddlewareArgs } from "@slack/bolt"; -import { danger } from "../../../globals.js"; +import { danger, logVerbose } from "../../../globals.js"; import { enqueueSystemEvent } from "../../../infra/system-events.js"; +import { authorizeSlackSystemEventSender } from "../auth.js"; import { resolveSlackChannelLabel } from "../channel-config.js"; import type { SlackMonitorContext } from "../context.js"; import type { SlackPinEvent } from "../types.js"; @@ -22,27 +23,28 @@ async function handleSlackPinEvent(params: { const payload = event as SlackPinEvent; const channelId = payload.channel_id; - const channelInfo = channelId ? await ctx.resolveChannelName(channelId) : {}; - if ( - !ctx.isChannelAllowed({ - channelId, - channelName: channelInfo?.name, - channelType: channelInfo?.type, - }) - ) { + const auth = await authorizeSlackSystemEventSender({ + ctx, + senderId: payload.user, + channelId, + }); + if (!auth.allowed) { + logVerbose( + `slack: drop pin sender ${payload.user ?? "unknown"} channel=${channelId ?? "unknown"} reason=${auth.reason ?? "unauthorized"}`, + ); return; } const label = resolveSlackChannelLabel({ channelId, - channelName: channelInfo?.name, + channelName: auth.channelName, }); const userInfo = payload.user ? await ctx.resolveUserName(payload.user) : {}; const userLabel = userInfo?.name ?? payload.user ?? "someone"; const itemType = payload.item?.type ?? "item"; const messageId = payload.item?.message?.ts ?? payload.event_ts; const sessionKey = ctx.resolveSlackSystemEventSessionKey({ channelId, - channelType: channelInfo?.type ?? undefined, + channelType: auth.channelType, }); enqueueSystemEvent(`Slack: ${userLabel} ${action} a ${itemType} in ${label}.`, { sessionKey,
src/slack/monitor/events/reactions.test.ts+30 −0 modified@@ -22,6 +22,7 @@ function createReactionContext(overrides?: { dmPolicy?: "open" | "pairing" | "allowlist" | "disabled"; allowFrom?: string[]; channelType?: "im" | "channel"; + channelUsers?: string[]; }) { let addedHandler: SlackReactionHandler | null = null; let removedHandler: SlackReactionHandler | null = null; @@ -38,7 +39,17 @@ function createReactionContext(overrides?: { const ctx = { app, runtime: { error: vi.fn() }, + dmEnabled: true, dmPolicy: overrides?.dmPolicy ?? "open", + defaultRequireMention: true, + channelsConfig: overrides?.channelUsers + ? { + C1: { + users: overrides.channelUsers, + allow: true, + }, + } + : undefined, groupPolicy: "open", allowFrom: overrides?.allowFrom ?? [], allowNameMatching: false, @@ -160,4 +171,23 @@ describe("registerSlackReactionEvents", () => { expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1); }); + + it("blocks channel reaction events for users outside channel users allowlist", async () => { + enqueueSystemEventMock.mockClear(); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + const { getAddedHandler } = createReactionContext({ + dmPolicy: "open", + channelType: "channel", + channelUsers: ["U_OWNER"], + }); + const addedHandler = getAddedHandler(); + expect(addedHandler).toBeTruthy(); + + await addedHandler!({ + event: makeReactionEvent({ channel: "C1", user: "U_ATTACKER" }), + body: {}, + }); + + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + }); });
src/slack/monitor/events/reactions.ts+12 −41 modified@@ -1,9 +1,7 @@ import type { SlackEventMiddlewareArgs } from "@slack/bolt"; import { danger, logVerbose } from "../../../globals.js"; import { enqueueSystemEvent } from "../../../infra/system-events.js"; -import { resolveDmGroupAccessWithLists } from "../../../security/dm-policy-shared.js"; -import { resolveSlackAllowListMatch } from "../allow-list.js"; -import { resolveSlackEffectiveAllowFrom } from "../auth.js"; +import { authorizeSlackSystemEventSender } from "../auth.js"; import { resolveSlackChannelLabel } from "../channel-config.js"; import type { SlackMonitorContext } from "../context.js"; import type { SlackReactionEvent } from "../types.js"; @@ -18,50 +16,23 @@ export function registerSlackReactionEvents(params: { ctx: SlackMonitorContext } return; } - const channelInfo = item.channel ? await ctx.resolveChannelName(item.channel) : {}; - const channelType = channelInfo?.type; - if ( - !ctx.isChannelAllowed({ - channelId: item.channel, - channelName: channelInfo?.name, - channelType, - }) - ) { + const auth = await authorizeSlackSystemEventSender({ + ctx, + senderId: event.user, + channelId: item.channel, + }); + if (!auth.allowed) { + logVerbose( + `slack: drop reaction sender ${event.user ?? "unknown"} channel=${item.channel ?? "unknown"} reason=${auth.reason ?? "unauthorized"}`, + ); return; } const channelLabel = resolveSlackChannelLabel({ channelId: item.channel, - channelName: channelInfo?.name, + channelName: auth.channelName, }); const actorInfo = event.user ? await ctx.resolveUserName(event.user) : undefined; - if (channelType === "im") { - if (!event.user) { - return; - } - const { allowFromLower } = await resolveSlackEffectiveAllowFrom(ctx); - const access = resolveDmGroupAccessWithLists({ - isGroup: false, - dmPolicy: ctx.dmPolicy, - groupPolicy: ctx.groupPolicy, - allowFrom: allowFromLower, - groupAllowFrom: [], - storeAllowFrom: [], - isSenderAllowed: (allowList) => - resolveSlackAllowListMatch({ - allowList, - id: event.user, - name: actorInfo?.name, - allowNameMatching: ctx.allowNameMatching, - }).allowed, - }); - if (access.decision !== "allow") { - logVerbose( - `slack: drop reaction sender ${event.user} (dmPolicy=${ctx.dmPolicy}, decision=${access.decision}, reason=${access.reason})`, - ); - return; - } - } const actorLabel = actorInfo?.name ?? event.user; const emojiLabel = event.reaction ?? "emoji"; const authorInfo = event.item_user ? await ctx.resolveUserName(event.item_user) : undefined; @@ -70,7 +41,7 @@ export function registerSlackReactionEvents(params: { ctx: SlackMonitorContext } const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText; const sessionKey = ctx.resolveSlackSystemEventSessionKey({ channelId: item.channel, - channelType, + channelType: auth.channelType, }); enqueueSystemEvent(text, { sessionKey,
aedf62ac7e66fix: harden discord and slack reaction ingress authorization
8 files changed · +483 −5
CHANGELOG.md+1 −0 modified@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Slack/Session threads: prevent oversized parent-session inheritance from silently bricking new thread sessions, surface embedded context-overflow empty-result failures to users, and add configurable `session.parentForkMaxTokens` (default `100000`, `0` disables). (#26912) Thanks @markshields-tl. - Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under `dmPolicy`/`groupPolicy`; reaction notifications now require channel access checks first. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. +- Security/Discord + Slack reactions: enforce DM policy/allowlist authorization before reaction-event system enqueue in direct messages; Discord reaction handling now also honors DM/group-DM enablement and guild `groupPolicy` channel gating to keep reaction ingress aligned with normal message preflight. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Telegram reactions: enforce `dmPolicy`/`allowFrom` and group allowlist authorization on `message_reaction` events before enqueueing reaction system events, preventing unauthorized reaction-triggered input in DMs and groups; ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy `oauth.json` onboarding path that exposed the PKCE verifier via OAuth `state`; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (`2026.2.25`). Thanks @zdi-disclosures for reporting. - Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting.
src/discord/monitor/listeners.ts+114 −1 modified@@ -7,14 +7,20 @@ import { PresenceUpdateListener, type User, } from "@buape/carbon"; -import { danger } from "../../globals.js"; +import { danger, logVerbose } from "../../globals.js"; import { formatDurationSeconds } from "../../infra/format-time/format-duration.ts"; import { enqueueSystemEvent } from "../../infra/system-events.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { readChannelAllowFromStore } from "../../pairing/pairing-store.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; +import { resolveDmGroupAccessWithLists } from "../../security/dm-policy-shared.js"; import { + isDiscordGroupAllowedByPolicy, + normalizeDiscordAllowList, normalizeDiscordSlug, + resolveDiscordAllowListMatch, resolveDiscordChannelConfigWithFallback, + resolveGroupDmAllow, resolveDiscordGuildEntry, shouldEmitDiscordReactionNotification, } from "./allow-list.js"; @@ -37,6 +43,12 @@ type DiscordReactionListenerParams = { accountId: string; runtime: RuntimeEnv; botUserId?: string; + dmEnabled: boolean; + groupDmEnabled: boolean; + groupDmChannels: string[]; + dmPolicy: "open" | "pairing" | "allowlist" | "disabled"; + allowFrom: string[]; + groupPolicy: "open" | "allowlist" | "disabled"; allowNameMatching: boolean; guildEntries?: Record<string, import("./allow-list.js").DiscordGuildEntryResolved>; logger: Logger; @@ -179,6 +191,12 @@ async function runDiscordReactionHandler(params: { cfg: params.handlerParams.cfg, accountId: params.handlerParams.accountId, botUserId: params.handlerParams.botUserId, + dmEnabled: params.handlerParams.dmEnabled, + groupDmEnabled: params.handlerParams.groupDmEnabled, + groupDmChannels: params.handlerParams.groupDmChannels, + dmPolicy: params.handlerParams.dmPolicy, + allowFrom: params.handlerParams.allowFrom, + groupPolicy: params.handlerParams.groupPolicy, allowNameMatching: params.handlerParams.allowNameMatching, guildEntries: params.handlerParams.guildEntries, logger: params.handlerParams.logger, @@ -193,6 +211,12 @@ async function handleDiscordReactionEvent(params: { cfg: LoadedConfig; accountId: string; botUserId?: string; + dmEnabled: boolean; + groupDmEnabled: boolean; + groupDmChannels: string[]; + dmPolicy: "open" | "pairing" | "allowlist" | "disabled"; + allowFrom: string[]; + groupPolicy: "open" | "allowlist" | "disabled"; allowNameMatching: boolean; guildEntries?: Record<string, import("./allow-list.js").DiscordGuildEntryResolved>; logger: Logger; @@ -236,6 +260,12 @@ async function handleDiscordReactionEvent(params: { channelType === ChannelType.PublicThread || channelType === ChannelType.PrivateThread || channelType === ChannelType.AnnouncementThread; + if (isDirectMessage && !params.dmEnabled) { + return; + } + if (isGroupDm && !params.groupDmEnabled) { + return; + } let parentId = "parentId" in channel ? (channel.parentId ?? undefined) : undefined; let parentName: string | undefined; let parentSlug = ""; @@ -264,6 +294,45 @@ async function handleDiscordReactionEvent(params: { reactionBase = { baseText, contextKey }; return reactionBase; }; + const isDirectReactionAuthorized = async () => { + if (!isDirectMessage) { + return true; + } + const storeAllowFrom = + params.dmPolicy === "allowlist" + ? [] + : await readChannelAllowFromStore("discord").catch(() => []); + const access = resolveDmGroupAccessWithLists({ + isGroup: false, + dmPolicy: params.dmPolicy, + groupPolicy: params.groupPolicy, + allowFrom: params.allowFrom, + groupAllowFrom: [], + storeAllowFrom, + isSenderAllowed: (allowEntries) => { + const allowList = normalizeDiscordAllowList(allowEntries, ["discord:", "user:", "pk:"]); + const allowMatch = allowList + ? resolveDiscordAllowListMatch({ + allowList, + candidate: { + id: user.id, + name: user.username, + tag: formatDiscordUserTag(user), + }, + allowNameMatching: params.allowNameMatching, + }) + : { allowed: false }; + return allowMatch.allowed; + }, + }); + if (access.decision !== "allow") { + logVerbose( + `discord reaction blocked sender=${user.id} (dmPolicy=${params.dmPolicy}, decision=${access.decision}, reason=${access.reason})`, + ); + return false; + } + return true; + }; const emitReaction = (text: string, parentPeerId?: string) => { const { contextKey } = resolveReactionBase(); const route = resolveAgentRoute({ @@ -322,6 +391,44 @@ async function handleDiscordReactionEvent(params: { parentSlug, scope: "thread", }); + const isGuildReactionAllowed = (channelConfig: { allowed?: boolean } | null) => { + if (!isGuildMessage) { + return true; + } + const channelAllowlistConfigured = + Boolean(guildInfo?.channels) && Object.keys(guildInfo?.channels ?? {}).length > 0; + const channelAllowed = channelConfig?.allowed !== false; + if ( + !isDiscordGroupAllowedByPolicy({ + groupPolicy: params.groupPolicy, + guildAllowlisted: Boolean(guildInfo), + channelAllowlistConfigured, + channelAllowed, + }) + ) { + return false; + } + if (channelConfig?.allowed === false) { + return false; + } + return true; + }; + + if (!(await isDirectReactionAuthorized())) { + return; + } + + if ( + isGroupDm && + !resolveGroupDmAllow({ + channels: params.groupDmChannels, + channelId: data.channel_id, + channelName, + channelSlug, + }) + ) { + return; + } // Parallelize async operations for thread channels if (isThreadChannel) { @@ -370,6 +477,9 @@ async function handleDiscordReactionEvent(params: { if (channelConfig?.allowed === false) { return; } + if (!isGuildReactionAllowed(channelConfig)) { + return; + } const messageAuthorId = message?.author?.id ?? undefined; if (!shouldNotifyReaction({ mode: reactionMode, messageAuthorId })) { @@ -394,6 +504,9 @@ async function handleDiscordReactionEvent(params: { if (channelConfig?.allowed === false) { return; } + if (!isGuildReactionAllowed(channelConfig)) { + return; + } const reactionMode = guildInfo?.reactionNotifications ?? "own";
src/discord/monitor/provider.ts+12 −0 modified@@ -561,6 +561,12 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { accountId: account.accountId, runtime, botUserId, + dmEnabled, + groupDmEnabled, + groupDmChannels: groupDmChannels ?? [], + dmPolicy, + allowFrom: allowFrom ?? [], + groupPolicy, allowNameMatching: isDangerousNameMatchingEnabled(discordCfg), guildEntries, logger, @@ -573,6 +579,12 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { accountId: account.accountId, runtime, botUserId, + dmEnabled, + groupDmEnabled, + groupDmChannels: groupDmChannels ?? [], + dmPolicy, + allowFrom: allowFrom ?? [], + groupPolicy, allowNameMatching: isDangerousNameMatchingEnabled(discordCfg), guildEntries, logger,
src/discord/monitor.test.ts+95 −3 modified@@ -1,5 +1,5 @@ import { ChannelType, type Guild } from "@buape/carbon"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { typedCases } from "../test-utils/typed-cases.js"; import { allowListMatches, @@ -20,6 +20,12 @@ import { } from "./monitor.js"; import { DiscordMessageListener, DiscordReactionListener } from "./monitor/listeners.js"; +const readAllowFromStoreMock = vi.hoisted(() => vi.fn()); + +vi.mock("../pairing/pairing-store.js", () => ({ + readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), +})); + const fakeGuild = (id: string, name: string) => ({ id, name }) as Guild; const makeEntries = ( @@ -899,6 +905,12 @@ function makeReactionClient(options?: { function makeReactionListenerParams(overrides?: { botUserId?: string; + dmEnabled?: boolean; + groupDmEnabled?: boolean; + groupDmChannels?: string[]; + dmPolicy?: "open" | "pairing" | "allowlist" | "disabled"; + allowFrom?: string[]; + groupPolicy?: "open" | "allowlist" | "disabled"; allowNameMatching?: boolean; guildEntries?: Record<string, DiscordGuildEntryResolved>; }) { @@ -907,6 +919,12 @@ function makeReactionListenerParams(overrides?: { accountId: "acc-1", runtime: {} as import("../runtime.js").RuntimeEnv, botUserId: overrides?.botUserId ?? "bot-1", + dmEnabled: overrides?.dmEnabled ?? true, + groupDmEnabled: overrides?.groupDmEnabled ?? true, + groupDmChannels: overrides?.groupDmChannels ?? [], + dmPolicy: overrides?.dmPolicy ?? "open", + allowFrom: overrides?.allowFrom ?? [], + groupPolicy: overrides?.groupPolicy ?? "open", allowNameMatching: overrides?.allowNameMatching ?? false, guildEntries: overrides?.guildEntries, logger: { @@ -919,6 +937,12 @@ function makeReactionListenerParams(overrides?: { } describe("discord DM reaction handling", () => { + beforeEach(() => { + enqueueSystemEventSpy.mockClear(); + resolveAgentRouteMock.mockClear(); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + }); + it("processes DM reactions with or without guild allowlists", async () => { const cases = [ { name: "no guild allowlist", guildEntries: undefined }, @@ -952,9 +976,77 @@ describe("discord DM reaction handling", () => { } }); + it("blocks DM reactions when dmPolicy is disabled", async () => { + const data = makeReactionEvent({ botAsAuthor: true }); + const client = makeReactionClient({ channelType: ChannelType.DM }); + const listener = new DiscordReactionListener( + makeReactionListenerParams({ dmPolicy: "disabled" }), + ); + + await listener.handle(data, client); + + expect(enqueueSystemEventSpy).not.toHaveBeenCalled(); + }); + + it("blocks DM reactions for unauthorized sender in allowlist mode", async () => { + const data = makeReactionEvent({ botAsAuthor: true, userId: "user-1" }); + const client = makeReactionClient({ channelType: ChannelType.DM }); + const listener = new DiscordReactionListener( + makeReactionListenerParams({ + dmPolicy: "allowlist", + allowFrom: ["user:user-2"], + }), + ); + + await listener.handle(data, client); + + expect(enqueueSystemEventSpy).not.toHaveBeenCalled(); + }); + + it("allows DM reactions for authorized sender in allowlist mode", async () => { + const data = makeReactionEvent({ botAsAuthor: true, userId: "user-1" }); + const client = makeReactionClient({ channelType: ChannelType.DM }); + const listener = new DiscordReactionListener( + makeReactionListenerParams({ + dmPolicy: "allowlist", + allowFrom: ["user:user-1"], + }), + ); + + await listener.handle(data, client); + + expect(enqueueSystemEventSpy).toHaveBeenCalledOnce(); + }); + + it("blocks group DM reactions when group DMs are disabled", async () => { + const data = makeReactionEvent({ botAsAuthor: true }); + const client = makeReactionClient({ channelType: ChannelType.GroupDM }); + const listener = new DiscordReactionListener( + makeReactionListenerParams({ groupDmEnabled: false }), + ); + + await listener.handle(data, client); + + expect(enqueueSystemEventSpy).not.toHaveBeenCalled(); + }); + + it("blocks guild reactions when groupPolicy is disabled", async () => { + const data = makeReactionEvent({ + guildId: "guild-123", + botAsAuthor: true, + guild: { id: "guild-123", name: "Guild" }, + }); + const client = makeReactionClient({ channelType: ChannelType.GuildText }); + const listener = new DiscordReactionListener( + makeReactionListenerParams({ groupPolicy: "disabled" }), + ); + + await listener.handle(data, client); + + expect(enqueueSystemEventSpy).not.toHaveBeenCalled(); + }); + it("still processes guild reactions (no regression)", async () => { - enqueueSystemEventSpy.mockClear(); - resolveAgentRouteMock.mockClear(); resolveAgentRouteMock.mockReturnValueOnce({ agentId: "default", channel: "discord",
src/security/dm-policy-shared.test.ts+32 −0 modified@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { resolveDmAllowState, resolveDmGroupAccessDecision, + resolveDmGroupAccessWithLists, resolveEffectiveAllowFromLists, } from "./dm-policy-shared.js"; @@ -75,6 +76,37 @@ describe("security/dm-policy-shared", () => { expect(lists.effectiveGroupAllowFrom).toEqual(["+1111", "+2222"]); }); + it("resolves access + effective allowlists in one shared call", () => { + const resolved = resolveDmGroupAccessWithLists({ + isGroup: false, + dmPolicy: "pairing", + groupPolicy: "allowlist", + allowFrom: ["owner"], + groupAllowFrom: ["group:room"], + storeAllowFrom: ["paired-user"], + isSenderAllowed: (allowFrom) => allowFrom.includes("paired-user"), + }); + expect(resolved.decision).toBe("allow"); + expect(resolved.reason).toBe("dmPolicy=pairing (allowlisted)"); + expect(resolved.effectiveAllowFrom).toEqual(["owner", "paired-user"]); + expect(resolved.effectiveGroupAllowFrom).toEqual(["group:room", "paired-user"]); + }); + + it("keeps allowlist mode strict in shared resolver (no pairing-store fallback)", () => { + const resolved = resolveDmGroupAccessWithLists({ + isGroup: false, + dmPolicy: "allowlist", + groupPolicy: "allowlist", + allowFrom: ["owner"], + groupAllowFrom: [], + storeAllowFrom: ["paired-user"], + isSenderAllowed: () => false, + }); + expect(resolved.decision).toBe("block"); + expect(resolved.reason).toBe("dmPolicy=allowlist (not allowlisted)"); + expect(resolved.effectiveAllowFrom).toEqual(["owner"]); + }); + const channels = [ "bluebubbles", "imessage",
src/security/dm-policy-shared.ts+35 −0 modified@@ -77,6 +77,41 @@ export function resolveDmGroupAccessDecision(params: { return { decision: "block", reason: `dmPolicy=${dmPolicy} (not allowlisted)` }; } +export function resolveDmGroupAccessWithLists(params: { + isGroup: boolean; + dmPolicy?: string | null; + groupPolicy?: string | null; + allowFrom?: Array<string | number> | null; + groupAllowFrom?: Array<string | number> | null; + storeAllowFrom?: Array<string | number> | null; + isSenderAllowed: (allowFrom: string[]) => boolean; +}): { + decision: DmGroupAccessDecision; + reason: string; + effectiveAllowFrom: string[]; + effectiveGroupAllowFrom: string[]; +} { + const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveEffectiveAllowFromLists({ + allowFrom: params.allowFrom, + groupAllowFrom: params.groupAllowFrom, + storeAllowFrom: params.storeAllowFrom, + dmPolicy: params.dmPolicy, + }); + const access = resolveDmGroupAccessDecision({ + isGroup: params.isGroup, + dmPolicy: params.dmPolicy, + groupPolicy: params.groupPolicy, + effectiveAllowFrom, + effectiveGroupAllowFrom, + isSenderAllowed: params.isSenderAllowed, + }); + return { + ...access, + effectiveAllowFrom, + effectiveGroupAllowFrom, + }; +} + export async function resolveDmAllowState(params: { provider: ChannelId; allowFrom?: Array<string | number> | null;
src/slack/monitor/events/reactions.test.ts+163 −0 added@@ -0,0 +1,163 @@ +import { describe, expect, it, vi } from "vitest"; +import type { SlackMonitorContext } from "../context.js"; +import { registerSlackReactionEvents } from "./reactions.js"; + +const enqueueSystemEventMock = vi.fn(); +const readAllowFromStoreMock = vi.fn(); + +vi.mock("../../../infra/system-events.js", () => ({ + enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args), +})); + +vi.mock("../../../pairing/pairing-store.js", () => ({ + readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), +})); + +type SlackReactionHandler = (args: { + event: Record<string, unknown>; + body: unknown; +}) => Promise<void>; + +function createReactionContext(overrides?: { + dmPolicy?: "open" | "pairing" | "allowlist" | "disabled"; + allowFrom?: string[]; + channelType?: "im" | "channel"; +}) { + let addedHandler: SlackReactionHandler | null = null; + let removedHandler: SlackReactionHandler | null = null; + const channelType = overrides?.channelType ?? "im"; + const app = { + event: vi.fn((name: string, handler: SlackReactionHandler) => { + if (name === "reaction_added") { + addedHandler = handler; + } else if (name === "reaction_removed") { + removedHandler = handler; + } + }), + }; + const ctx = { + app, + runtime: { error: vi.fn() }, + dmPolicy: overrides?.dmPolicy ?? "open", + groupPolicy: "open", + allowFrom: overrides?.allowFrom ?? [], + allowNameMatching: false, + shouldDropMismatchedSlackEvent: vi.fn().mockReturnValue(false), + isChannelAllowed: vi.fn().mockReturnValue(true), + resolveChannelName: vi.fn().mockResolvedValue({ + name: channelType === "im" ? "direct" : "general", + type: channelType, + }), + resolveUserName: vi.fn().mockResolvedValue({ name: "alice" }), + resolveSlackSystemEventSessionKey: vi.fn().mockReturnValue("agent:main:main"), + } as unknown as SlackMonitorContext; + registerSlackReactionEvents({ ctx }); + return { + ctx, + getAddedHandler: () => addedHandler, + getRemovedHandler: () => removedHandler, + }; +} + +function makeReactionEvent(overrides?: { user?: string; channel?: string }) { + return { + type: "reaction_added", + user: overrides?.user ?? "U1", + reaction: "thumbsup", + item: { + type: "message", + channel: overrides?.channel ?? "D1", + ts: "123.456", + }, + item_user: "UBOT", + }; +} + +describe("registerSlackReactionEvents", () => { + it("enqueues DM reaction system events when dmPolicy is open", async () => { + enqueueSystemEventMock.mockClear(); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + const { getAddedHandler } = createReactionContext({ dmPolicy: "open" }); + const addedHandler = getAddedHandler(); + expect(addedHandler).toBeTruthy(); + + await addedHandler!({ + event: makeReactionEvent(), + body: {}, + }); + + expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1); + }); + + it("blocks DM reaction system events when dmPolicy is disabled", async () => { + enqueueSystemEventMock.mockClear(); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + const { getAddedHandler } = createReactionContext({ dmPolicy: "disabled" }); + const addedHandler = getAddedHandler(); + expect(addedHandler).toBeTruthy(); + + await addedHandler!({ + event: makeReactionEvent(), + body: {}, + }); + + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + }); + + it("blocks DM reaction system events for unauthorized senders in allowlist mode", async () => { + enqueueSystemEventMock.mockClear(); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + const { getAddedHandler } = createReactionContext({ + dmPolicy: "allowlist", + allowFrom: ["U2"], + }); + const addedHandler = getAddedHandler(); + expect(addedHandler).toBeTruthy(); + + await addedHandler!({ + event: makeReactionEvent({ user: "U1" }), + body: {}, + }); + + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + }); + + it("allows DM reaction system events for authorized senders in allowlist mode", async () => { + enqueueSystemEventMock.mockClear(); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + const { getAddedHandler } = createReactionContext({ + dmPolicy: "allowlist", + allowFrom: ["U1"], + }); + const addedHandler = getAddedHandler(); + expect(addedHandler).toBeTruthy(); + + await addedHandler!({ + event: makeReactionEvent({ user: "U1" }), + body: {}, + }); + + expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1); + }); + + it("enqueues channel reaction events regardless of dmPolicy", async () => { + enqueueSystemEventMock.mockClear(); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + const { getRemovedHandler } = createReactionContext({ + dmPolicy: "disabled", + channelType: "channel", + }); + const removedHandler = getRemovedHandler(); + expect(removedHandler).toBeTruthy(); + + await removedHandler!({ + event: { + ...makeReactionEvent({ channel: "C1" }), + type: "reaction_removed", + }, + body: {}, + }); + + expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1); + }); +});
src/slack/monitor/events/reactions.ts+31 −1 modified@@ -1,6 +1,9 @@ import type { SlackEventMiddlewareArgs } from "@slack/bolt"; -import { danger } from "../../../globals.js"; +import { danger, logVerbose } from "../../../globals.js"; import { enqueueSystemEvent } from "../../../infra/system-events.js"; +import { resolveDmGroupAccessWithLists } from "../../../security/dm-policy-shared.js"; +import { resolveSlackAllowListMatch } from "../allow-list.js"; +import { resolveSlackEffectiveAllowFrom } from "../auth.js"; import { resolveSlackChannelLabel } from "../channel-config.js"; import type { SlackMonitorContext } from "../context.js"; import type { SlackReactionEvent } from "../types.js"; @@ -32,6 +35,33 @@ export function registerSlackReactionEvents(params: { ctx: SlackMonitorContext } channelName: channelInfo?.name, }); const actorInfo = event.user ? await ctx.resolveUserName(event.user) : undefined; + if (channelType === "im") { + if (!event.user) { + return; + } + const { allowFromLower } = await resolveSlackEffectiveAllowFrom(ctx); + const access = resolveDmGroupAccessWithLists({ + isGroup: false, + dmPolicy: ctx.dmPolicy, + groupPolicy: ctx.groupPolicy, + allowFrom: allowFromLower, + groupAllowFrom: [], + storeAllowFrom: [], + isSenderAllowed: (allowList) => + resolveSlackAllowListMatch({ + allowList, + id: event.user, + name: actorInfo?.name, + allowNameMatching: ctx.allowNameMatching, + }).allowed, + }); + if (access.decision !== "allow") { + logVerbose( + `slack: drop reaction sender ${event.user} (dmPolicy=${ctx.dmPolicy}, decision=${access.decision}, reason=${access.reason})`, + ); + return; + } + } const actorLabel = actorInfo?.name ?? event.user; const emojiLabel = event.reaction ?? "emoji"; const authorInfo = event.item_user ? await ctx.resolveUserName(event.item_user) : undefined;
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/openclaw/openclaw/commit/75dfb71e4e8b7c2feba5a8ca662f92ea840e0147ghsapatchWEB
- github.com/openclaw/openclaw/commit/aedf62ac7e669a89c7b299201bf6537dc6b12e0eghsapatchWEB
- github.com/advisories/GHSA-rm2p-j3r7-4x4jghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-rm2p-j3r7-4x4jghsathird-party-advisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-32899ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-sender-policy-bypass-in-slack-reaction-and-pin-event-handlersghsathird-party-advisoryWEB
News mentions
0No linked articles in our index yet.