mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 15:30:39 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user