VYPR
Medium severity5.4NVD Advisory· Published Apr 23, 2026· Updated Apr 29, 2026

CVE-2026-41341

CVE-2026-41341

Description

OpenClaw before 2026.3.31 contains a logic error in Discord component interaction routing that misclassifies group direct messages as direct messages in extensions/discord/src/monitor/agent-components-helpers.ts. Attackers can exploit this misclassification to bypass group DM policy enforcement or trigger incorrect session handling.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.3.312026.3.31

Affected products

1
  • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*
    Range: <2026.3.31

Patches

1
8c83128fc38d

Discord: fix Group DM component interaction routing and auth (#57763)

https://github.com/openclaw/openclawDevin RobisonMar 30, 2026via ghsa
4 files changed · +310 31
  • extensions/discord/src/monitor/agent-components-helpers.ts+61 2 modified
    @@ -33,6 +33,7 @@ import {
       isDiscordGroupAllowedByPolicy,
       normalizeDiscordAllowList,
       normalizeDiscordSlug,
    +  resolveGroupDmAllow,
       resolveDiscordAllowListMatch,
       resolveDiscordChannelConfigWithFallback,
       resolveDiscordGuildEntry,
    @@ -146,6 +147,7 @@ export function resolveAgentComponentRoute(params: {
       rawGuildId: string | undefined;
       memberRoleIds: string[];
       isDirectMessage: boolean;
    +  isGroupDm: boolean;
       userId: string;
       channelId: string;
       parentId: string | undefined;
    @@ -157,7 +159,7 @@ export function resolveAgentComponentRoute(params: {
         guildId: params.rawGuildId,
         memberRoleIds: params.memberRoleIds,
         peer: {
    -      kind: params.isDirectMessage ? "direct" : "channel",
    +      kind: params.isDirectMessage ? "direct" : params.isGroupDm ? "group" : "channel",
           id: params.isDirectMessage ? params.userId : params.channelId,
         },
         parentPeer: params.parentId ? { kind: "channel", id: params.parentId } : undefined,
    @@ -238,7 +240,10 @@ export async function resolveComponentInteractionContext(params: {
       const username = formatUsername(user);
       const userId = user.id;
       const rawGuildId = interaction.rawData.guild_id;
    -  const isDirectMessage = !rawGuildId;
    +  const channelType = resolveDiscordChannelContext(interaction).channelType;
    +  const isGroupDm = channelType === ChannelType.GroupDM;
    +  const isDirectMessage =
    +    channelType === ChannelType.DM || (!rawGuildId && !isGroupDm && channelType == null);
       const memberRoleIds = Array.isArray(interaction.rawData.member?.roles)
         ? interaction.rawData.member.roles.map((roleId: string) => String(roleId))
         : [];
    @@ -251,6 +256,7 @@ export async function resolveComponentInteractionContext(params: {
         replyOpts,
         rawGuildId,
         isDirectMessage,
    +    isGroupDm,
         memberRoleIds,
       };
     }
    @@ -563,6 +569,47 @@ async function ensureDmComponentAuthorized(params: {
       return false;
     }
     
    +async function ensureGroupDmComponentAuthorized(params: {
    +  ctx: AgentComponentContext;
    +  interaction: AgentComponentInteraction;
    +  channelId: string;
    +  componentLabel: string;
    +  replyOpts: { ephemeral?: boolean };
    +}) {
    +  const { ctx, interaction, channelId, componentLabel, replyOpts } = params;
    +  const groupDmEnabled = ctx.discordConfig?.dm?.groupEnabled ?? false;
    +  if (!groupDmEnabled) {
    +    logVerbose(`agent ${componentLabel}: blocked group dm ${channelId} (group DMs disabled)`);
    +    try {
    +      await interaction.reply({
    +        content: "Group DM interactions are disabled.",
    +        ...replyOpts,
    +      });
    +    } catch {}
    +    return false;
    +  }
    +
    +  const channelCtx = resolveDiscordChannelContext(interaction);
    +  const allowed = resolveGroupDmAllow({
    +    channels: ctx.discordConfig?.dm?.groupChannels,
    +    channelId,
    +    channelName: channelCtx.channelName,
    +    channelSlug: channelCtx.channelSlug,
    +  });
    +  if (allowed) {
    +    return true;
    +  }
    +
    +  logVerbose(`agent ${componentLabel}: blocked group dm ${channelId} (not allowlisted)`);
    +  try {
    +    await interaction.reply({
    +      content: `You are not authorized to use this ${componentLabel}.`,
    +      ...replyOpts,
    +    });
    +  } catch {}
    +  return false;
    +}
    +
     export async function resolveInteractionContextWithDmAuth(params: {
       ctx: AgentComponentContext;
       interaction: AgentComponentInteraction;
    @@ -590,6 +637,18 @@ export async function resolveInteractionContextWithDmAuth(params: {
           return null;
         }
       }
    +  if (interactionCtx.isGroupDm) {
    +    const authorized = await ensureGroupDmComponentAuthorized({
    +      ctx: params.ctx,
    +      interaction: params.interaction,
    +      channelId: interactionCtx.channelId,
    +      componentLabel: params.componentLabel,
    +      replyOpts: interactionCtx.replyOpts,
    +    });
    +    if (!authorized) {
    +      return null;
    +    }
    +  }
       return interactionCtx;
     }
     
    
  • extensions/discord/src/monitor/agent-components.ts+72 29 modified
    @@ -46,7 +46,6 @@ import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/rep
     import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime";
     import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-runtime";
     import { createReplyReferencePlanner } from "openclaw/plugin-sdk/reply-runtime";
    -import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
     import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
     import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
     import { logDebug, logError } from "openclaw/plugin-sdk/text-runtime";
    @@ -115,6 +114,34 @@ function resolveComponentGroupPolicy(
       }).groupPolicy;
     }
     
    +function buildDiscordComponentConversationLabel(params: {
    +  interactionCtx: ComponentInteractionContext;
    +  interaction: AgentComponentInteraction;
    +  channelCtx: DiscordChannelContext;
    +}) {
    +  if (params.interactionCtx.isDirectMessage) {
    +    return buildDirectLabel(params.interactionCtx.user);
    +  }
    +  if (params.interactionCtx.isGroupDm) {
    +    return `Group DM #${params.channelCtx.channelName ?? params.interactionCtx.channelId} channel id:${params.interactionCtx.channelId}`;
    +  }
    +  return buildGuildLabel({
    +    guild: params.interaction.guild ?? undefined,
    +    channelName: params.channelCtx.channelName ?? params.interactionCtx.channelId,
    +    channelId: params.interactionCtx.channelId,
    +  });
    +}
    +
    +function resolveDiscordComponentChatType(interactionCtx: ComponentInteractionContext) {
    +  if (interactionCtx.isDirectMessage) {
    +    return "direct";
    +  }
    +  if (interactionCtx.isGroupDm) {
    +    return "group";
    +  }
    +  return "channel";
    +}
    +
     async function dispatchPluginDiscordInteractiveEvent(params: {
       ctx: AgentComponentContext;
       interaction: AgentComponentInteraction;
    @@ -289,29 +316,25 @@ async function dispatchDiscordComponentEvent(params: {
     }): Promise<void> {
       const { ctx, interaction, interactionCtx, channelCtx, guildInfo, eventText } = params;
       const runtime = ctx.runtime ?? createNonExitingRuntime();
    -  const route = resolveAgentRoute({
    -    cfg: ctx.cfg,
    -    channel: "discord",
    -    accountId: ctx.accountId,
    -    guildId: interactionCtx.rawGuildId,
    +  const route = resolveAgentComponentRoute({
    +    ctx,
    +    rawGuildId: interactionCtx.rawGuildId,
         memberRoleIds: interactionCtx.memberRoleIds,
    -    peer: {
    -      kind: interactionCtx.isDirectMessage ? "direct" : "channel",
    -      id: interactionCtx.isDirectMessage ? interactionCtx.userId : interactionCtx.channelId,
    -    },
    -    parentPeer: channelCtx.parentId ? { kind: "channel", id: channelCtx.parentId } : undefined,
    +    isDirectMessage: interactionCtx.isDirectMessage,
    +    isGroupDm: interactionCtx.isGroupDm,
    +    userId: interactionCtx.userId,
    +    channelId: interactionCtx.channelId,
    +    parentId: channelCtx.parentId,
       });
       const sessionKey = params.routeOverrides?.sessionKey ?? route.sessionKey;
       const agentId = params.routeOverrides?.agentId ?? route.agentId;
       const accountId = params.routeOverrides?.accountId ?? route.accountId;
    -
    -  const fromLabel = interactionCtx.isDirectMessage
    -    ? buildDirectLabel(interactionCtx.user)
    -    : buildGuildLabel({
    -        guild: interaction.guild ?? undefined,
    -        channelName: channelCtx.channelName ?? interactionCtx.channelId,
    -        channelId: interactionCtx.channelId,
    -      });
    +  const fromLabel = buildDiscordComponentConversationLabel({
    +    interactionCtx,
    +    interaction,
    +    channelCtx,
    +  });
    +  const chatType = resolveDiscordComponentChatType(interactionCtx);
       const senderName = interactionCtx.user.globalName ?? interactionCtx.user.username;
       const senderUsername = interactionCtx.user.username;
       const senderTag = formatDiscordUserTag(interactionCtx.user);
    @@ -369,7 +392,7 @@ async function dispatchDiscordComponentEvent(params: {
         from: fromLabel,
         timestamp,
         body: eventText,
    -    chatType: interactionCtx.isDirectMessage ? "direct" : "channel",
    +    chatType,
         senderLabel: senderName,
         previousTimestamp,
         envelope: envelopeOptions,
    @@ -382,11 +405,13 @@ async function dispatchDiscordComponentEvent(params: {
         CommandBody: eventText,
         From: interactionCtx.isDirectMessage
           ? `discord:${interactionCtx.userId}`
    -      : `discord:channel:${interactionCtx.channelId}`,
    +      : interactionCtx.isGroupDm
    +        ? `discord:group:${interactionCtx.channelId}`
    +        : `discord:channel:${interactionCtx.channelId}`,
         To: `channel:${interactionCtx.channelId}`,
         SessionKey: sessionKey,
         AccountId: accountId,
    -    ChatType: interactionCtx.isDirectMessage ? "direct" : "channel",
    +    ChatType: chatType,
         ConversationLabel: fromLabel,
         SenderName: senderName,
         SenderId: interactionCtx.userId,
    @@ -694,6 +719,7 @@ async function handleDiscordModalTrigger(params: {
       interaction: ButtonInteraction;
       data: ComponentData;
       label: string;
    +  interactionCtx?: ComponentInteractionContext;
     }): Promise<void> {
       const parsed = parseDiscordComponentData(
         params.data,
    @@ -737,13 +763,15 @@ async function handleDiscordModalTrigger(params: {
         return;
       }
     
    -  const interactionCtx = await resolveInteractionContextWithDmAuth({
    -    ctx: params.ctx,
    -    interaction: params.interaction,
    -    label: params.label,
    -    componentLabel: "form",
    -    defer: false,
    -  });
    +  const interactionCtx =
    +    params.interactionCtx ??
    +    (await resolveInteractionContextWithDmAuth({
    +      ctx: params.ctx,
    +      interaction: params.interaction,
    +      label: params.label,
    +      componentLabel: "form",
    +      defer: false,
    +    }));
       if (!interactionCtx) {
         return;
       }
    @@ -870,6 +898,7 @@ export class AgentComponentButton extends Button {
           replyOpts,
           rawGuildId,
           isDirectMessage,
    +      isGroupDm,
           memberRoleIds,
         } = interactionCtx;
     
    @@ -896,6 +925,7 @@ export class AgentComponentButton extends Button {
           rawGuildId,
           memberRoleIds,
           isDirectMessage,
    +      isGroupDm,
           userId,
           channelId,
           parentId,
    @@ -960,6 +990,7 @@ export class AgentSelectMenu extends StringSelectMenu {
           replyOpts,
           rawGuildId,
           isDirectMessage,
    +      isGroupDm,
           memberRoleIds,
         } = interactionCtx;
     
    @@ -989,6 +1020,7 @@ export class AgentSelectMenu extends StringSelectMenu {
           rawGuildId,
           memberRoleIds,
           isDirectMessage,
    +      isGroupDm,
           userId,
           channelId,
           parentId,
    @@ -1022,11 +1054,22 @@ class DiscordComponentButton extends Button {
       async run(interaction: ButtonInteraction, data: ComponentData): Promise<void> {
         const parsed = parseDiscordComponentData(data, resolveInteractionCustomId(interaction));
         if (parsed?.modalId) {
    +      const interactionCtx = await resolveInteractionContextWithDmAuth({
    +        ctx: this.ctx,
    +        interaction,
    +        label: "discord component button",
    +        componentLabel: "form",
    +        defer: false,
    +      });
    +      if (!interactionCtx) {
    +        return;
    +      }
           await handleDiscordModalTrigger({
             ctx: this.ctx,
             interaction,
             data,
             label: "discord component modal",
    +        interactionCtx,
           });
           return;
         }
    
  • extensions/discord/src/monitor/monitor.agent-components.test.ts+107 0 modified
    @@ -1,4 +1,5 @@
     import type { ButtonInteraction, ComponentData, StringSelectMenuInteraction } from "@buape/carbon";
    +import { ChannelType } from "discord-api-types/v10";
     import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
     import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime";
     import * as conversationRuntime from "openclaw/plugin-sdk/conversation-runtime";
    @@ -12,6 +13,7 @@ import {
       resetDiscordComponentRuntimeMocks,
       upsertPairingRequestMock,
     } from "../test-support/component-runtime.js";
    +import { resolveComponentInteractionContext } from "./agent-components-helpers.js";
     import { createAgentComponentButton, createAgentSelectMenu } from "./agent-components.js";
     
     describe("agent components", () => {
    @@ -21,6 +23,12 @@ describe("agent components", () => {
         accountId: "default",
         peer: { kind: "direct", id: "123456789" },
       });
    +  const defaultGroupDmSessionKey = buildAgentSessionKey({
    +    agentId: "main",
    +    channel: "discord",
    +    accountId: "default",
    +    peer: { kind: "group", id: "group-dm-channel" },
    +  });
     
       const createCfg = (): OpenClawConfig => ({}) as OpenClawConfig;
     
    @@ -60,6 +68,35 @@ describe("agent components", () => {
         };
       };
     
    +  const createBaseGroupDmInteraction = (overrides: Record<string, unknown> = {}) => {
    +    const reply = vi.fn().mockResolvedValue(undefined);
    +    const defer = vi.fn().mockResolvedValue(undefined);
    +    const interaction = {
    +      rawData: { channel_id: "group-dm-channel" },
    +      channel: {
    +        id: "group-dm-channel",
    +        type: ChannelType.GroupDM,
    +        name: "incident-room",
    +      },
    +      user: { id: "123456789", username: "Alice", discriminator: "1234" },
    +      defer,
    +      reply,
    +      ...overrides,
    +    };
    +    return { interaction, defer, reply };
    +  };
    +
    +  const createGroupDmButtonInteraction = (overrides: Partial<ButtonInteraction> = {}) => {
    +    const { interaction, defer, reply } = createBaseGroupDmInteraction(
    +      overrides as Record<string, unknown>,
    +    );
    +    return {
    +      interaction: interaction as unknown as ButtonInteraction,
    +      defer,
    +      reply,
    +    };
    +  };
    +
       beforeEach(() => {
         resetDiscordComponentRuntimeMocks();
         resetSystemEventsForTest();
    @@ -117,6 +154,76 @@ describe("agent components", () => {
         expect(readAllowFromStoreMock).not.toHaveBeenCalled();
       });
     
    +  it("classifies Group DM component interactions separately from direct messages", async () => {
    +    const { interaction, defer } = createGroupDmButtonInteraction();
    +
    +    const ctx = await resolveComponentInteractionContext({
    +      interaction,
    +      label: "group-dm-test",
    +      defer: false,
    +    });
    +
    +    expect(defer).not.toHaveBeenCalled();
    +    expect(ctx).toMatchObject({
    +      channelId: "group-dm-channel",
    +      isDirectMessage: false,
    +      isGroupDm: true,
    +      rawGuildId: undefined,
    +      userId: "123456789",
    +    });
    +  });
    +
    +  it("blocks Group DM interactions that are not allowlisted even when dmPolicy is open", async () => {
    +    const button = createAgentComponentButton({
    +      cfg: createCfg(),
    +      accountId: "default",
    +      dmPolicy: "open",
    +      discordConfig: {
    +        dm: {
    +          groupEnabled: true,
    +          groupChannels: ["other-group-dm"],
    +        },
    +      } as DiscordAccountConfig,
    +    });
    +    const { interaction, defer, reply } = createGroupDmButtonInteraction();
    +
    +    await button.run(interaction, { componentId: "hello" } as ComponentData);
    +
    +    expect(defer).not.toHaveBeenCalled();
    +    expect(reply).toHaveBeenCalledWith({
    +      content: "You are not authorized to use this button.",
    +      ephemeral: true,
    +    });
    +    expect(peekSystemEvents(defaultGroupDmSessionKey)).toEqual([]);
    +    expect(peekSystemEvents(defaultDmSessionKey)).toEqual([]);
    +    expect(readAllowFromStoreMock).not.toHaveBeenCalled();
    +  });
    +
    +  it("routes allowlisted Group DM interactions to the group session without applying DM policy", async () => {
    +    const button = createAgentComponentButton({
    +      cfg: createCfg(),
    +      accountId: "default",
    +      dmPolicy: "disabled",
    +      discordConfig: {
    +        dm: {
    +          groupEnabled: true,
    +          groupChannels: ["group-dm-channel"],
    +        },
    +      } as DiscordAccountConfig,
    +    });
    +    const { interaction, defer, reply } = createGroupDmButtonInteraction();
    +
    +    await button.run(interaction, { componentId: "hello" } as ComponentData);
    +
    +    expect(defer).not.toHaveBeenCalled();
    +    expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
    +    expect(peekSystemEvents(defaultGroupDmSessionKey)).toEqual([
    +      "[Discord component: hello clicked by Alice#1234 (123456789)]",
    +    ]);
    +    expect(peekSystemEvents(defaultDmSessionKey)).toEqual([]);
    +    expect(readAllowFromStoreMock).not.toHaveBeenCalled();
    +  });
    +
       it("authorizes DM interactions from pairing-store entries in pairing mode", async () => {
         readAllowFromStoreMock.mockResolvedValue(["123456789"]);
         const button = createAgentComponentButton({
    
  • extensions/discord/src/monitor/monitor.test.ts+70 0 modified
    @@ -701,6 +701,76 @@ describe("discord component interactions", () => {
         expect(dispatchReplyMock).not.toHaveBeenCalled();
       });
     
    +  it("marks built-in Group DM component fallbacks with group metadata", async () => {
    +    registerDiscordComponentEntries({
    +      entries: [createButtonEntry()],
    +      modals: [],
    +    });
    +
    +    const button = createDiscordComponentButton(
    +      createComponentContext({
    +        discordConfig: createDiscordConfig({
    +          dm: {
    +            groupEnabled: true,
    +            groupChannels: ["group-dm-1"],
    +          },
    +        }),
    +      }),
    +    );
    +    const { interaction, reply } = createComponentButtonInteraction({
    +      rawData: {
    +        channel_id: "group-dm-1",
    +        id: "interaction-group-dm-fallback",
    +      } as unknown as ButtonInteraction["rawData"],
    +      channel: {
    +        id: "group-dm-1",
    +        type: ChannelType.GroupDM,
    +        name: "incident-room",
    +      } as unknown as ButtonInteraction["channel"],
    +    });
    +
    +    await button.run(interaction, { cid: "btn_1" } as ComponentData);
    +
    +    expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
    +    expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
    +    expect(lastDispatchCtx).toMatchObject({
    +      From: "discord:group:group-dm-1",
    +      ChatType: "group",
    +      ConversationLabel: "Group DM #incident-room channel id:group-dm-1",
    +    });
    +  });
    +
    +  it("blocks Group DM modal triggers before showing the modal", async () => {
    +    registerDiscordComponentEntries({
    +      entries: [createButtonEntry({ kind: "modal-trigger", modalId: "mdl_1" })],
    +      modals: [createModalEntry()],
    +    });
    +
    +    const button = createDiscordComponentButton(createComponentContext());
    +    const showModal = vi.fn().mockResolvedValue(undefined);
    +    const { interaction, reply } = createComponentButtonInteraction({
    +      rawData: {
    +        channel_id: "group-dm-1",
    +        id: "interaction-group-dm-modal-trigger",
    +      } as unknown as ButtonInteraction["rawData"],
    +      channel: {
    +        id: "group-dm-1",
    +        type: ChannelType.GroupDM,
    +        name: "incident-room",
    +      } as unknown as ButtonInteraction["channel"],
    +      showModal,
    +    });
    +
    +    await button.run(interaction, { cid: "btn_1", mid: "mdl_1" } as ComponentData);
    +
    +    expect(reply).toHaveBeenCalledWith({
    +      content: "Group DM interactions are disabled.",
    +      ephemeral: true,
    +    });
    +    expect(showModal).not.toHaveBeenCalled();
    +    expect(dispatchReplyMock).not.toHaveBeenCalled();
    +  });
    +
       it("does not fall through to Claw when a plugin Discord interaction already replied", async () => {
         registerDiscordComponentEntries({
           entries: [createButtonEntry({ callbackData: "codex:approve" })],
    

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

News mentions

0

No linked articles in our index yet.