fix(security): gate slash/control commands
This commit is contained in:
73
src/channels/command-gating.test.ts
Normal file
73
src/channels/command-gating.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
||||
24
src/channels/command-gating.ts
Normal file
24
src/channels/command-gating.ts
Normal 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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user