Discord: enforce strict DM component allowlist auth (#49997)

* Discord: enforce strict DM component allowlist auth

* Discord: align model picker fallback routing

* changelog

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>

---------

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>
This commit is contained in:
Josh Avant
2026-03-18 20:11:47 -05:00
committed by GitHub
parent 7b151afeeb
commit 0f0cecd2e8
5 changed files with 106 additions and 18 deletions

View File

@@ -132,6 +132,7 @@ Docs: https://docs.openclaw.ai
- Models/chat commands: keep `/model ...@YYYYMMDD` version suffixes intact by default, but still honor matching stored numeric auth-profile overrides for the same provider. (#48896) Thanks @Alix-007.
- Gateway/channels: serialize per-account channel startup so overlapping starts do not boot the same provider twice, preventing MS Teams `EADDRINUSE` crash loops during startup and restart. (#49583) Thanks @sudie-codes.
- Tests/OpenAI Codex auth: align login expectations with the default `gpt-5.4` model so CI coverage stays consistent with the current OpenAI Codex default. (#44367) Thanks @jrrcdev.
- Discord: enforce strict DM component allowlist auth (#49997) Thanks @joshavant.
### Fixes

View File

@@ -429,6 +429,21 @@ async function ensureDmComponentAuthorized(params: {
replyOpts: { ephemeral?: boolean };
}) {
const { ctx, interaction, user, componentLabel, replyOpts } = params;
const allowFromPrefixes = ["discord:", "user:", "pk:"];
const resolveAllowMatch = (entries: string[]) => {
const allowList = normalizeDiscordAllowList(entries, allowFromPrefixes);
return allowList
? resolveDiscordAllowListMatch({
allowList,
candidate: {
id: user.id,
name: user.username,
tag: formatDiscordUserTag(user),
},
allowNameMatching: isDangerousNameMatchingEnabled(ctx.discordConfig),
})
: { allowed: false };
};
const dmPolicy = ctx.dmPolicy ?? "pairing";
if (dmPolicy === "disabled") {
logVerbose(`agent ${componentLabel}: blocked (DM policy disabled)`);
@@ -444,24 +459,27 @@ async function ensureDmComponentAuthorized(params: {
return true;
}
if (dmPolicy === "allowlist") {
const allowMatch = resolveAllowMatch(ctx.allowFrom ?? []);
if (allowMatch.allowed) {
return true;
}
logVerbose(`agent ${componentLabel}: blocked DM user ${user.id} (not in allowFrom)`);
try {
await interaction.reply({
content: `You are not authorized to use this ${componentLabel}.`,
...replyOpts,
});
} catch {}
return false;
}
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
provider: "discord",
accountId: ctx.accountId,
dmPolicy,
});
const effectiveAllowFrom = [...(ctx.allowFrom ?? []), ...storeAllowFrom];
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:", "pk:"]);
const allowMatch = allowList
? resolveDiscordAllowListMatch({
allowList,
candidate: {
id: user.id,
name: user.username,
tag: formatDiscordUserTag(user),
},
allowNameMatching: isDangerousNameMatchingEnabled(ctx.discordConfig),
})
: { allowed: false };
const allowMatch = resolveAllowMatch([...(ctx.allowFrom ?? []), ...storeAllowFrom]);
if (allowMatch.allowed) {
return true;
}

View File

@@ -191,10 +191,14 @@ describe("agent components", () => {
expect(reply).toHaveBeenCalledTimes(1);
expect(reply.mock.calls[0]?.[0]?.content).toContain("Pairing code: PAIRCODE");
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
expect(readAllowFromStoreMock).toHaveBeenCalledWith({
provider: "discord",
accountId: "default",
dmPolicy: "pairing",
});
});
it("blocks DM interactions when only pairing store entries match in allowlist mode", async () => {
readAllowFromStoreMock.mockResolvedValue(["123456789"]);
it("blocks DM interactions in allowlist mode when sender is not in configured allowFrom", async () => {
const button = createAgentComponentButton({
cfg: createCfg(),
accountId: "default",
@@ -210,6 +214,62 @@ describe("agent components", () => {
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
it("authorizes DM interactions from pairing-store entries in pairing mode", async () => {
readAllowFromStoreMock.mockResolvedValue(["123456789"]);
const button = createAgentComponentButton({
cfg: createCfg(),
accountId: "default",
dmPolicy: "pairing",
});
const { interaction, defer, reply } = createDmButtonInteraction();
await button.run(interaction, { componentId: "hello" } as ComponentData);
expect(defer).toHaveBeenCalledWith({ ephemeral: true });
expect(reply).toHaveBeenCalledWith({ content: "✓" });
expect(enqueueSystemEventMock).toHaveBeenCalled();
expect(upsertPairingRequestMock).not.toHaveBeenCalled();
expect(readAllowFromStoreMock).toHaveBeenCalledWith({
provider: "discord",
accountId: "default",
dmPolicy: "pairing",
});
});
it("allows DM component interactions in open mode without reading pairing store", async () => {
readAllowFromStoreMock.mockResolvedValue(["123456789"]);
const button = createAgentComponentButton({
cfg: createCfg(),
accountId: "default",
dmPolicy: "open",
});
const { interaction, defer, reply } = createDmButtonInteraction();
await button.run(interaction, { componentId: "hello" } as ComponentData);
expect(defer).toHaveBeenCalledWith({ ephemeral: true });
expect(reply).toHaveBeenCalledWith({ content: "✓" });
expect(enqueueSystemEventMock).toHaveBeenCalled();
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
it("blocks DM component interactions in disabled mode without reading pairing store", async () => {
readAllowFromStoreMock.mockResolvedValue(["123456789"]);
const button = createAgentComponentButton({
cfg: createCfg(),
accountId: "default",
dmPolicy: "disabled",
});
const { interaction, defer, reply } = createDmButtonInteraction();
await button.run(interaction, { componentId: "hello" } as ComponentData);
expect(defer).toHaveBeenCalledWith({ ephemeral: true });
expect(reply).toHaveBeenCalledWith({ content: "DM interactions are disabled." });
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
it("matches tag-based allowlist entries for DM select menus", async () => {
const select = createAgentSelectMenu({
cfg: createCfg(),
@@ -225,6 +285,7 @@ describe("agent components", () => {
expect(defer).toHaveBeenCalledWith({ ephemeral: true });
expect(reply).toHaveBeenCalledWith({ content: "✓" });
expect(enqueueSystemEventMock).toHaveBeenCalled();
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
it("accepts cid payloads for agent button interactions", async () => {
@@ -244,6 +305,7 @@ describe("agent components", () => {
expect.stringContaining("hello_cid"),
expect.any(Object),
);
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
it("keeps malformed percent cid values without throwing", async () => {
@@ -263,6 +325,7 @@ describe("agent components", () => {
expect.stringContaining("hello%2G"),
expect.any(Object),
);
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
});

View File

@@ -38,6 +38,7 @@ import {
type DiscordModelPickerPreferenceScope,
} from "./model-picker-preferences.js";
import {
DISCORD_MODEL_PICKER_CUSTOM_ID_KEY,
loadDiscordModelPickerData,
parseDiscordModelPickerData,
renderDiscordModelPickerModelsView,
@@ -949,7 +950,7 @@ class DiscordCommandArgFallbackButton extends Button {
class DiscordModelPickerFallbackButton extends Button {
label = "modelpick";
customId = "modelpick:seed=btn";
customId = `${DISCORD_MODEL_PICKER_CUSTOM_ID_KEY}:seed=btn`;
private ctx: DiscordModelPickerContext;
private safeInteractionCall: SafeDiscordInteractionCall;
private dispatchCommandInteraction: DispatchDiscordCommandInteraction;
@@ -977,7 +978,7 @@ class DiscordModelPickerFallbackButton extends Button {
}
class DiscordModelPickerFallbackSelect extends StringSelectMenu {
customId = "modelpick:seed=sel";
customId = `${DISCORD_MODEL_PICKER_CUSTOM_ID_KEY}:seed=sel`;
options = [];
private ctx: DiscordModelPickerContext;
private safeInteractionCall: SafeDiscordInteractionCall;

View File

@@ -246,7 +246,12 @@ describe("Discord model picker interactions", () => {
const select = createDiscordModelPickerFallbackSelect(context);
expect(button.customId).not.toBe(select.customId);
expect(button.customId.split(":")[0]).toBe(select.customId.split(":")[0]);
expect(button.customId.split(":")[0]).toBe(
modelPickerModule.DISCORD_MODEL_PICKER_CUSTOM_ID_KEY,
);
expect(select.customId.split(":")[0]).toBe(
modelPickerModule.DISCORD_MODEL_PICKER_CUSTOM_ID_KEY,
);
});
it("ignores interactions from users other than the picker owner", async () => {