mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 20:00:42 +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.
|
- 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.
|
- 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.
|
- 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
|
## 2026.2.24
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../../auto-
|
|||||||
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
|
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
|
||||||
import { dispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
|
import { dispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
|
||||||
import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.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 { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
|
||||||
import { recordInboundSession } from "../../channels/session.js";
|
import { recordInboundSession } from "../../channels/session.js";
|
||||||
import type { OpenClawConfig } from "../../config/config.js";
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
@@ -727,6 +728,57 @@ function formatModalSubmissionText(
|
|||||||
return lines.join("\n");
|
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: {
|
async function dispatchDiscordComponentEvent(params: {
|
||||||
ctx: AgentComponentContext;
|
ctx: AgentComponentContext;
|
||||||
interaction: AgentComponentInteraction;
|
interaction: AgentComponentInteraction;
|
||||||
@@ -780,12 +832,20 @@ async function dispatchDiscordComponentEvent(params: {
|
|||||||
parentSlug: channelCtx.parentSlug,
|
parentSlug: channelCtx.parentSlug,
|
||||||
scope: channelCtx.isThread ? "thread" : "channel",
|
scope: channelCtx.isThread ? "thread" : "channel",
|
||||||
});
|
});
|
||||||
|
const allowNameMatching = isDangerousNameMatchingEnabled(ctx.discordConfig);
|
||||||
const groupSystemPrompt = channelConfig?.systemPrompt?.trim() || undefined;
|
const groupSystemPrompt = channelConfig?.systemPrompt?.trim() || undefined;
|
||||||
const ownerAllowFrom = resolveDiscordOwnerAllowFrom({
|
const ownerAllowFrom = resolveDiscordOwnerAllowFrom({
|
||||||
channelConfig,
|
channelConfig,
|
||||||
guildInfo,
|
guildInfo,
|
||||||
sender: { id: interactionCtx.user.id, name: interactionCtx.user.username, tag: senderTag },
|
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 storePath = resolveStorePath(ctx.cfg.session?.store, { agentId });
|
||||||
const envelopeOptions = resolveEnvelopeFormatOptions(ctx.cfg);
|
const envelopeOptions = resolveEnvelopeFormatOptions(ctx.cfg);
|
||||||
@@ -830,7 +890,7 @@ async function dispatchDiscordComponentEvent(params: {
|
|||||||
Provider: "discord" as const,
|
Provider: "discord" as const,
|
||||||
Surface: "discord" as const,
|
Surface: "discord" as const,
|
||||||
WasMentioned: true,
|
WasMentioned: true,
|
||||||
CommandAuthorized: true,
|
CommandAuthorized: commandAuthorized,
|
||||||
CommandSource: "text" as const,
|
CommandSource: "text" as const,
|
||||||
MessageSid: interaction.rawData.id,
|
MessageSid: interaction.rawData.id,
|
||||||
Timestamp: timestamp,
|
Timestamp: timestamp,
|
||||||
|
|||||||
@@ -391,6 +391,70 @@ describe("discord component interactions", () => {
|
|||||||
expect(resolveDiscordModalEntry({ id: "mdl_1" })).toBeNull();
|
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 () => {
|
it("keeps reusable modal entries active after submission", async () => {
|
||||||
const { acknowledge } = await runModalSubmission({ reusable: true });
|
const { acknowledge } = await runModalSubmission({ reusable: true });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user