fix: harden discord guild allowlist resolution

This commit is contained in:
Peter Steinberger 2026-03-14 02:08:57 +00:00
parent 5c73ed62d5
commit 10afde99c1
10 changed files with 127 additions and 9 deletions

View File

@ -58,6 +58,7 @@ Docs: https://docs.openclaw.ai
- Dashboard/chat UI: render oversized plain-text replies as normal paragraphs instead of capped gray code blocks, so long desktop chat responses stay readable without tab-switching refreshes. - Dashboard/chat UI: render oversized plain-text replies as normal paragraphs instead of capped gray code blocks, so long desktop chat responses stay readable without tab-switching refreshes.
- Gateway/Control UI: restore the operator-only device-auth bypass and classify browser connect failures so origin and device-identity problems no longer show up as auth errors in the Control UI and web chat. (#45512) thanks @sallyom. - Gateway/Control UI: restore the operator-only device-auth bypass and classify browser connect failures so origin and device-identity problems no longer show up as auth errors in the Control UI and web chat. (#45512) thanks @sallyom.
- macOS/voice wake: stop crashing wake-word command extraction when speech segment ranges come from a different transcript instance. - macOS/voice wake: stop crashing wake-word command extraction when speech segment ranges come from a different transcript instance.
- Discord/allowlists: honor raw `guild_id` when hydrated guild objects are missing so allowlisted channels and threads like `#maintainers` no longer get false-dropped before channel allowlist checks.
## 2026.3.12 ## 2026.3.12

View File

@ -247,6 +247,18 @@ describe("discord guild/channel resolution", () => {
expect(resolved?.slug).toBe("friends-of-openclaw"); expect(resolved?.slug).toBe("friends-of-openclaw");
}); });
it("resolves guild entry by raw guild id when guild object is missing", () => {
const guildEntries = makeEntries({
"123": { slug: "friends-of-openclaw" },
});
const resolved = resolveDiscordGuildEntry({
guildId: "123",
guildEntries,
});
expect(resolved?.id).toBe("123");
expect(resolved?.slug).toBe("friends-of-openclaw");
});
it("resolves guild entry by slug key", () => { it("resolves guild entry by slug key", () => {
const guildEntries = makeEntries({ const guildEntries = makeEntries({
"friends-of-openclaw": { slug: "friends-of-openclaw" }, "friends-of-openclaw": { slug: "friends-of-openclaw" },

View File

@ -360,6 +360,7 @@ async function ensureAgentComponentInteractionAllowed(params: {
}): Promise<{ parentId: string | undefined } | null> { }): Promise<{ parentId: string | undefined } | null> {
const guildInfo = resolveDiscordGuildEntry({ const guildInfo = resolveDiscordGuildEntry({
guild: params.interaction.guild ?? undefined, guild: params.interaction.guild ?? undefined,
guildId: params.rawGuildId,
guildEntries: params.ctx.guildEntries, guildEntries: params.ctx.guildEntries,
}); });
const channelCtx = resolveDiscordChannelContext(params.interaction); const channelCtx = resolveDiscordChannelContext(params.interaction);
@ -1094,6 +1095,7 @@ async function handleDiscordComponentEvent(params: {
const { channelId, user, replyOpts, rawGuildId, memberRoleIds } = interactionCtx; const { channelId, user, replyOpts, rawGuildId, memberRoleIds } = interactionCtx;
const guildInfo = resolveDiscordGuildEntry({ const guildInfo = resolveDiscordGuildEntry({
guild: params.interaction.guild ?? undefined, guild: params.interaction.guild ?? undefined,
guildId: rawGuildId,
guildEntries: params.ctx.guildEntries, guildEntries: params.ctx.guildEntries,
}); });
const channelCtx = resolveDiscordChannelContext(params.interaction); const channelCtx = resolveDiscordChannelContext(params.interaction);
@ -1246,6 +1248,7 @@ async function handleDiscordModalTrigger(params: {
const { channelId, user, replyOpts, rawGuildId, memberRoleIds } = interactionCtx; const { channelId, user, replyOpts, rawGuildId, memberRoleIds } = interactionCtx;
const guildInfo = resolveDiscordGuildEntry({ const guildInfo = resolveDiscordGuildEntry({
guild: params.interaction.guild ?? undefined, guild: params.interaction.guild ?? undefined,
guildId: rawGuildId,
guildEntries: params.ctx.guildEntries, guildEntries: params.ctx.guildEntries,
}); });
const channelCtx = resolveDiscordChannelContext(params.interaction); const channelCtx = resolveDiscordChannelContext(params.interaction);
@ -1696,6 +1699,7 @@ class DiscordComponentModal extends Modal {
const { channelId, user, replyOpts, rawGuildId, memberRoleIds } = interactionCtx; const { channelId, user, replyOpts, rawGuildId, memberRoleIds } = interactionCtx;
const guildInfo = resolveDiscordGuildEntry({ const guildInfo = resolveDiscordGuildEntry({
guild: interaction.guild ?? undefined, guild: interaction.guild ?? undefined,
guildId: rawGuildId,
guildEntries: this.ctx.guildEntries, guildEntries: this.ctx.guildEntries,
}); });
const channelCtx = resolveDiscordChannelContext(interaction); const channelCtx = resolveDiscordChannelContext(interaction);

View File

@ -321,25 +321,30 @@ export function resolveDiscordCommandAuthorized(params: {
export function resolveDiscordGuildEntry(params: { export function resolveDiscordGuildEntry(params: {
guild?: Guild<true> | Guild | null; guild?: Guild<true> | Guild | null;
guildId?: string | null;
guildEntries?: Record<string, DiscordGuildEntryResolved>; guildEntries?: Record<string, DiscordGuildEntryResolved>;
}): DiscordGuildEntryResolved | null { }): DiscordGuildEntryResolved | null {
const guild = params.guild; const guild = params.guild;
const entries = params.guildEntries; const entries = params.guildEntries;
if (!guild || !entries) { const guildId = params.guildId?.trim() || guild?.id;
if (!entries) {
return null; return null;
} }
const byId = entries[guild.id]; const byId = guildId ? entries[guildId] : undefined;
if (byId) { if (byId) {
return { ...byId, id: guild.id }; return { ...byId, id: guildId };
}
if (!guild) {
return null;
} }
const slug = normalizeDiscordSlug(guild.name ?? ""); const slug = normalizeDiscordSlug(guild.name ?? "");
const bySlug = entries[slug]; const bySlug = entries[slug];
if (bySlug) { if (bySlug) {
return { ...bySlug, id: guild.id, slug: slug || bySlug.slug }; return { ...bySlug, id: guildId ?? guild.id, slug: slug || bySlug.slug };
} }
const wildcard = entries["*"]; const wildcard = entries["*"];
if (wildcard) { if (wildcard) {
return { ...wildcard, id: guild.id, slug: slug || wildcard.slug }; return { ...wildcard, id: guildId ?? guild.id, slug: slug || wildcard.slug };
} }
return null; return null;
} }

View File

@ -430,6 +430,7 @@ async function handleDiscordReactionEvent(
const guildInfo = isGuildMessage const guildInfo = isGuildMessage
? resolveDiscordGuildEntry({ ? resolveDiscordGuildEntry({
guild: data.guild ?? undefined, guild: data.guild ?? undefined,
guildId: data.guild_id ?? undefined,
guildEntries, guildEntries,
}) })
: null; : null;

View File

@ -34,14 +34,19 @@ export function createGuildEvent(params: {
guildId: string; guildId: string;
author: import("@buape/carbon").Message["author"]; author: import("@buape/carbon").Message["author"];
message: import("@buape/carbon").Message; message: import("@buape/carbon").Message;
includeGuildObject?: boolean;
}): DiscordMessageEvent { }): DiscordMessageEvent {
return { return {
channel_id: params.channelId, channel_id: params.channelId,
guild_id: params.guildId, guild_id: params.guildId,
guild: { ...(params.includeGuildObject === false
id: params.guildId, ? {}
name: "Guild One", : {
}, guild: {
id: params.guildId,
name: "Guild One",
},
}),
author: params.author, author: params.author,
message: params.message, message: params.message,
} as unknown as DiscordMessageEvent; } as unknown as DiscordMessageEvent;

View File

@ -138,6 +138,7 @@ async function runGuildPreflight(params: {
discordConfig: DiscordConfig; discordConfig: DiscordConfig;
cfg?: import("../../config/config.js").OpenClawConfig; cfg?: import("../../config/config.js").OpenClawConfig;
guildEntries?: Parameters<typeof preflightDiscordMessage>[0]["guildEntries"]; guildEntries?: Parameters<typeof preflightDiscordMessage>[0]["guildEntries"];
includeGuildObject?: boolean;
}) { }) {
return preflightDiscordMessage({ return preflightDiscordMessage({
...createPreflightArgs({ ...createPreflightArgs({
@ -148,6 +149,7 @@ async function runGuildPreflight(params: {
guildId: params.guildId, guildId: params.guildId,
author: params.message.author, author: params.message.author,
message: params.message, message: params.message,
includeGuildObject: params.includeGuildObject,
}), }),
client: createGuildTextClient(params.channelId), client: createGuildTextClient(params.channelId),
}), }),
@ -374,6 +376,91 @@ describe("preflightDiscordMessage", () => {
expect(result).not.toBeNull(); expect(result).not.toBeNull();
}); });
it("accepts allowlisted guild messages when guild object is missing", async () => {
const message = createDiscordMessage({
id: "m-guild-id-only",
channelId: "ch-1",
content: "hello from maintainers",
author: {
id: "user-1",
bot: false,
username: "Peter",
},
});
const result = await runGuildPreflight({
channelId: "ch-1",
guildId: "guild-1",
message,
discordConfig: {} as DiscordConfig,
guildEntries: {
"guild-1": {
channels: {
"ch-1": {
allow: true,
requireMention: false,
},
},
},
},
includeGuildObject: false,
});
expect(result).not.toBeNull();
expect(result?.guildInfo?.id).toBe("guild-1");
expect(result?.channelConfig?.allowed).toBe(true);
expect(result?.shouldRequireMention).toBe(false);
});
it("inherits parent thread allowlist when guild object is missing", async () => {
const threadId = "thread-1";
const parentId = "parent-1";
const message = createDiscordMessage({
id: "m-thread-id-only",
channelId: threadId,
content: "thread hello",
author: {
id: "user-1",
bot: false,
username: "Peter",
},
});
const result = await preflightDiscordMessage({
...createPreflightArgs({
cfg: DEFAULT_PREFLIGHT_CFG,
discordConfig: {} as DiscordConfig,
data: createGuildEvent({
channelId: threadId,
guildId: "guild-1",
author: message.author,
message,
includeGuildObject: false,
}),
client: createThreadClient({
threadId,
parentId,
}),
}),
guildEntries: {
"guild-1": {
channels: {
[parentId]: {
allow: true,
requireMention: false,
},
},
},
},
});
expect(result).not.toBeNull();
expect(result?.guildInfo?.id).toBe("guild-1");
expect(result?.threadParentId).toBe(parentId);
expect(result?.channelConfig?.allowed).toBe(true);
expect(result?.shouldRequireMention).toBe(false);
});
it("drops guild messages that mention another user when ignoreOtherMentions=true", async () => { it("drops guild messages that mention another user when ignoreOtherMentions=true", async () => {
const channelId = "channel-other-mention-1"; const channelId = "channel-other-mention-1";
const guildId = "guild-other-mention-1"; const guildId = "guild-other-mention-1";

View File

@ -430,6 +430,7 @@ export async function preflightDiscordMessage(
const guildInfo = isGuildMessage const guildInfo = isGuildMessage
? resolveDiscordGuildEntry({ ? resolveDiscordGuildEntry({
guild: params.data.guild ?? undefined, guild: params.data.guild ?? undefined,
guildId: params.data.guild_id ?? undefined,
guildEntries: params.guildEntries, guildEntries: params.guildEntries,
}) })
: null; : null;

View File

@ -1355,6 +1355,7 @@ async function dispatchDiscordCommandInteraction(params: {
}); });
const guildInfo = resolveDiscordGuildEntry({ const guildInfo = resolveDiscordGuildEntry({
guild: interaction.guild ?? undefined, guild: interaction.guild ?? undefined,
guildId: interaction.guild?.id ?? undefined,
guildEntries: discordConfig?.guilds, guildEntries: discordConfig?.guilds,
}); });
let threadParentId: string | undefined; let threadParentId: string | undefined;

View File

@ -107,6 +107,7 @@ async function authorizeVoiceCommand(
const guildInfo = resolveDiscordGuildEntry({ const guildInfo = resolveDiscordGuildEntry({
guild: interaction.guild ?? undefined, guild: interaction.guild ?? undefined,
guildId: interaction.guild?.id ?? interaction.rawData.guild_id ?? undefined,
guildEntries: params.discordConfig.guilds, guildEntries: params.discordConfig.guilds,
}); });