fix(discord): gate component command authorization for guild interactions (#26119)

* Discord: gate component command authorization

* test: cover allowlisted guild component authorization path (#26119) (thanks @bmendonca3)

---------

Co-authored-by: Brian Mendonca <brianmendonca@Brians-MacBook-Air.local>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
bmendonca3
2026-02-24 21:57:41 -07:00
committed by GitHub
parent 24a60799be
commit c1964e73a8
3 changed files with 127 additions and 2 deletions

View File

@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
- Agents/Model fallback: continue fallback traversal on unrecognized errors when candidates remain, while still throwing the original unknown error on the last candidate. (#26106) Thanks @Sid-Qin.
- Telegram/Markdown spoilers: keep valid `||spoiler||` pairs while leaving unmatched trailing `||` delimiters as literal text, avoiding false all-or-nothing spoiler suppression. (#26105) Thanks @Sid-Qin.
- Hooks/Inbound metadata: include `guildId` and `channelName` in `message_received` metadata for both plugin and internal hook paths. (#26115) Thanks @davidrudduck.
- Discord/Component auth: evaluate guild component interactions with command-gating authorizers so unauthorized users no longer get `CommandAuthorized: true` on modal/button events. (#26119) Thanks @bmendonca3.
## 2026.2.24

View File

@@ -23,6 +23,7 @@ import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../../auto-
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
import { dispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
import { recordInboundSession } from "../../channels/session.js";
import type { OpenClawConfig } from "../../config/config.js";
@@ -727,6 +728,57 @@ function formatModalSubmissionText(
return lines.join("\n");
}
function resolveComponentCommandAuthorized(params: {
ctx: AgentComponentContext;
interactionCtx: ComponentInteractionContext;
channelConfig: ReturnType<typeof resolveDiscordChannelConfigWithFallback>;
guildInfo: ReturnType<typeof resolveDiscordGuildEntry>;
allowNameMatching: boolean;
}): boolean {
const { ctx, interactionCtx, channelConfig, guildInfo } = params;
if (interactionCtx.isDirectMessage) {
return true;
}
const ownerAllowList = normalizeDiscordAllowList(ctx.allowFrom, ["discord:", "user:", "pk:"]);
const ownerOk = ownerAllowList
? resolveDiscordAllowListMatch({
allowList: ownerAllowList,
candidate: {
id: interactionCtx.user.id,
name: interactionCtx.user.username,
tag: formatDiscordUserTag(interactionCtx.user),
},
allowNameMatching: params.allowNameMatching,
}).allowed
: false;
const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({
channelConfig,
guildInfo,
memberRoleIds: interactionCtx.memberRoleIds,
sender: {
id: interactionCtx.user.id,
name: interactionCtx.user.username,
tag: formatDiscordUserTag(interactionCtx.user),
},
allowNameMatching: params.allowNameMatching,
});
const useAccessGroups = ctx.cfg.commands?.useAccessGroups !== false;
const authorizers = useAccessGroups
? [
{ configured: ownerAllowList != null, allowed: ownerOk },
{ configured: hasAccessRestrictions, allowed: memberAllowed },
]
: [{ configured: hasAccessRestrictions, allowed: memberAllowed }];
return resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers,
modeWhenAccessGroupsOff: "configured",
});
}
async function dispatchDiscordComponentEvent(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
@@ -780,12 +832,20 @@ async function dispatchDiscordComponentEvent(params: {
parentSlug: channelCtx.parentSlug,
scope: channelCtx.isThread ? "thread" : "channel",
});
const allowNameMatching = isDangerousNameMatchingEnabled(ctx.discordConfig);
const groupSystemPrompt = channelConfig?.systemPrompt?.trim() || undefined;
const ownerAllowFrom = resolveDiscordOwnerAllowFrom({
channelConfig,
guildInfo,
sender: { id: interactionCtx.user.id, name: interactionCtx.user.username, tag: senderTag },
allowNameMatching: isDangerousNameMatchingEnabled(ctx.discordConfig),
allowNameMatching,
});
const commandAuthorized = resolveComponentCommandAuthorized({
ctx,
interactionCtx,
channelConfig,
guildInfo,
allowNameMatching,
});
const storePath = resolveStorePath(ctx.cfg.session?.store, { agentId });
const envelopeOptions = resolveEnvelopeFormatOptions(ctx.cfg);
@@ -830,7 +890,7 @@ async function dispatchDiscordComponentEvent(params: {
Provider: "discord" as const,
Surface: "discord" as const,
WasMentioned: true,
CommandAuthorized: true,
CommandAuthorized: commandAuthorized,
CommandSource: "text" as const,
MessageSid: interaction.rawData.id,
Timestamp: timestamp,

View File

@@ -391,6 +391,70 @@ describe("discord component interactions", () => {
expect(resolveDiscordModalEntry({ id: "mdl_1" })).toBeNull();
});
it("does not mark guild modal events as command-authorized for non-allowlisted users", async () => {
registerDiscordComponentEntries({
entries: [],
modals: [createModalEntry()],
});
const modal = createDiscordComponentModal(
createComponentContext({
cfg: {
commands: { useAccessGroups: true },
channels: { discord: { replyToMode: "first" } },
} as OpenClawConfig,
allowFrom: ["owner-1"],
}),
);
const { interaction, acknowledge } = createModalInteraction({
rawData: {
channel_id: "guild-channel",
guild_id: "guild-1",
id: "interaction-guild-1",
member: { roles: [] },
} as unknown as ModalInteraction["rawData"],
guild: { id: "guild-1", name: "Test Guild" } as unknown as ModalInteraction["guild"],
});
await modal.run(interaction, { mid: "mdl_1" } as ComponentData);
expect(acknowledge).toHaveBeenCalledTimes(1);
expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
expect(lastDispatchCtx?.CommandAuthorized).toBe(false);
});
it("marks guild modal events as command-authorized for allowlisted users", async () => {
registerDiscordComponentEntries({
entries: [],
modals: [createModalEntry()],
});
const modal = createDiscordComponentModal(
createComponentContext({
cfg: {
commands: { useAccessGroups: true },
channels: { discord: { replyToMode: "first" } },
} as OpenClawConfig,
allowFrom: ["123456789"],
}),
);
const { interaction, acknowledge } = createModalInteraction({
rawData: {
channel_id: "guild-channel",
guild_id: "guild-1",
id: "interaction-guild-2",
member: { roles: [] },
} as unknown as ModalInteraction["rawData"],
guild: { id: "guild-1", name: "Test Guild" } as unknown as ModalInteraction["guild"],
});
await modal.run(interaction, { mid: "mdl_1" } as ComponentData);
expect(acknowledge).toHaveBeenCalledTimes(1);
expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
expect(lastDispatchCtx?.CommandAuthorized).toBe(true);
});
it("keeps reusable modal entries active after submission", async () => {
const { acknowledge } = await runModalSubmission({ reusable: true });