fix(security): gate slash/control commands

This commit is contained in:
Peter Steinberger
2026-01-17 06:49:17 +00:00
parent 7ed55682b7
commit 6a3ed5c850
22 changed files with 758 additions and 203 deletions

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { resolveCommandAuthorizedFromAuthorizers } from "./command-gating.js";
describe("resolveCommandAuthorizedFromAuthorizers", () => {
it("denies when useAccessGroups is enabled and no authorizer is configured", () => {
expect(
resolveCommandAuthorizedFromAuthorizers({
useAccessGroups: true,
authorizers: [{ configured: false, allowed: true }],
}),
).toBe(false);
});
it("allows when useAccessGroups is enabled and any configured authorizer allows", () => {
expect(
resolveCommandAuthorizedFromAuthorizers({
useAccessGroups: true,
authorizers: [
{ configured: true, allowed: false },
{ configured: true, allowed: true },
],
}),
).toBe(true);
});
it("allows when useAccessGroups is disabled (default)", () => {
expect(
resolveCommandAuthorizedFromAuthorizers({
useAccessGroups: false,
authorizers: [{ configured: true, allowed: false }],
}),
).toBe(true);
});
it("honors modeWhenAccessGroupsOff=deny", () => {
expect(
resolveCommandAuthorizedFromAuthorizers({
useAccessGroups: false,
authorizers: [{ configured: false, allowed: true }],
modeWhenAccessGroupsOff: "deny",
}),
).toBe(false);
});
it("honors modeWhenAccessGroupsOff=configured (allow when none configured)", () => {
expect(
resolveCommandAuthorizedFromAuthorizers({
useAccessGroups: false,
authorizers: [{ configured: false, allowed: false }],
modeWhenAccessGroupsOff: "configured",
}),
).toBe(true);
});
it("honors modeWhenAccessGroupsOff=configured (enforce when configured)", () => {
expect(
resolveCommandAuthorizedFromAuthorizers({
useAccessGroups: false,
authorizers: [{ configured: true, allowed: false }],
modeWhenAccessGroupsOff: "configured",
}),
).toBe(false);
expect(
resolveCommandAuthorizedFromAuthorizers({
useAccessGroups: false,
authorizers: [{ configured: true, allowed: true }],
modeWhenAccessGroupsOff: "configured",
}),
).toBe(true);
});
});

View File

@@ -0,0 +1,24 @@
export type CommandAuthorizer = {
configured: boolean;
allowed: boolean;
};
export type CommandGatingModeWhenAccessGroupsOff = "allow" | "deny" | "configured";
export function resolveCommandAuthorizedFromAuthorizers(params: {
useAccessGroups: boolean;
authorizers: CommandAuthorizer[];
modeWhenAccessGroupsOff?: CommandGatingModeWhenAccessGroupsOff;
}): boolean {
const { useAccessGroups, authorizers } = params;
const mode = params.modeWhenAccessGroupsOff ?? "allow";
if (!useAccessGroups) {
if (mode === "allow") return true;
if (mode === "deny") return false;
const anyConfigured = authorizers.some((entry) => entry.configured);
if (!anyConfigured) return true;
return authorizers.some((entry) => entry.configured && entry.allowed);
}
return authorizers.some((entry) => entry.configured && entry.allowed);
}

View File

@@ -14,7 +14,6 @@ import {
sendMessageDiscord,
sendPollDiscord,
} from "../../discord/send.js";
import { resolveNativeCommandsEnabled } from "../../config/commands.js";
import { shouldLogVerbose } from "../../globals.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import { getChatChannelMeta } from "../registry.js";
@@ -120,7 +119,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
normalizeEntry: (raw) => raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"),
};
},
collectWarnings: ({ cfg, account }) => {
collectWarnings: ({ account }) => {
const warnings: string[] = [];
const groupPolicy = account.config.groupPolicy ?? "allowlist";
const guildEntries = account.config.guilds ?? {};
@@ -139,33 +138,6 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
}
}
const nativeCommandsEnabled = resolveNativeCommandsEnabled({
providerId: "discord",
providerSetting: account.config.commands?.native,
globalSetting: cfg.commands?.native,
});
if (nativeCommandsEnabled && guildsConfigured) {
const hasAnyUserAllowlist = Object.values(guildEntries).some((guild) => {
if (!guild || typeof guild !== "object") return false;
if (Array.isArray(guild.users) && guild.users.length > 0) return true;
const channels = guild.channels;
if (!channels || typeof channels !== "object") return false;
return Object.values(channels).some(
(channel) =>
Boolean(channel) &&
typeof channel === "object" &&
Array.isArray(channel.users) &&
channel.users.length > 0,
);
});
if (!hasAnyUserAllowlist) {
warnings.push(
`- Discord slash commands: no users allowlist configured; this allows any user in allowed guild channels to invoke /… commands. Set channels.discord.guilds.<id>.users (or channels.discord.guilds.<id>.channels.<channel>.users).`,
);
}
}
return warnings;
},
},

View File

@@ -13,7 +13,6 @@ import { probeSlack } from "../../slack/probe.js";
import { sendMessageSlack } from "../../slack/send.js";
import { getChatChannelMeta } from "../registry.js";
import { SlackConfigSchema } from "../../config/zod-schema.providers-core.js";
import { resolveNativeCommandsEnabled } from "../../config/commands.js";
import { buildChannelConfigSchema } from "./config-schema.js";
import {
deleteAccountFromConfigSection,
@@ -135,13 +134,11 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
normalizeEntry: (raw) => raw.replace(/^(slack|user):/i, ""),
};
},
collectWarnings: ({ cfg, account }) => {
collectWarnings: ({ account }) => {
const warnings: string[] = [];
const groupPolicy = account.config.groupPolicy ?? "allowlist";
const channelAllowlistConfigured =
Boolean(account.config.channels) && Object.keys(account.config.channels ?? {}).length > 0;
const roomAccessPossible =
groupPolicy === "open" || (groupPolicy === "allowlist" && channelAllowlistConfigured);
if (groupPolicy === "open") {
if (channelAllowlistConfigured) {
@@ -155,30 +152,6 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
}
}
const nativeEnabled = resolveNativeCommandsEnabled({
providerId: "slack",
providerSetting: account.config.commands?.native,
globalSetting: cfg.commands?.native,
});
const slashCommandEnabled = nativeEnabled || account.config.slashCommand?.enabled === true;
if (slashCommandEnabled && roomAccessPossible) {
const hasAnyUserAllowlist = Object.values(account.config.channels ?? {}).some(
(channel) => Array.isArray(channel.users) && channel.users.length > 0,
);
if (!hasAnyUserAllowlist) {
warnings.push(
`- Slack slash commands: no channel users allowlist configured; this allows any user in allowed channels to invoke /… commands (including skill commands). Set channels.slack.channels.<id>.users.`,
);
}
}
if (slashCommandEnabled && cfg.commands?.useAccessGroups === false) {
warnings.push(
`- Slack slash commands: commands.useAccessGroups=false disables channel allowlist gating; this allows any channel to invoke /… commands (including skill commands). Set commands.useAccessGroups=true and configure channels.slack.groupPolicy/channels.`,
);
}
return warnings;
},
},

View File

@@ -1,4 +1,6 @@
import type { ChannelMeta } from "./plugins/types.js";
import type { ChannelId } from "./plugins/types.js";
import { getActivePluginRegistry } from "../plugins/runtime.js";
// Channel docking: add new channels here (order + meta + aliases), then
// register the plugin in src/channels/plugins/index.ts and keep protocol IDs in sync.
@@ -111,6 +113,29 @@ export function normalizeChannelId(raw?: string | null): ChatChannelId | null {
return normalizeChatChannelId(raw);
}
// Normalizes core chat channels plus any *already-loaded* plugin channels.
//
// Keep this light: we do not import core channel plugins here (those are "heavy" and can pull in
// monitors, web login, etc). If plugins are not loaded (e.g. in many tests), only core channel IDs
// resolve.
export function normalizeAnyChannelId(raw?: string | null): ChannelId | null {
const core = normalizeChatChannelId(raw);
if (core) return core;
const key = normalizeChannelKey(raw);
if (!key) return null;
const registry = getActivePluginRegistry();
if (!registry) return null;
const hit = registry.channels.find((entry) => {
const id = String(entry.plugin.id ?? "").trim().toLowerCase();
if (id && id === key) return true;
return (entry.plugin.meta.aliases ?? []).some((alias) => alias.trim().toLowerCase() === key);
});
return (hit?.plugin.id as ChannelId | undefined) ?? null;
}
export function formatChannelPrimerLine(meta: ChatChannelMeta): string {
return `${meta.label}: ${meta.blurb}`;
}