diff --git a/CHANGELOG.md b/CHANGELOG.md index b1ee30adb15..571d67255fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai - Security/LINE webhook: require signatures for empty-event POST probes too so unsigned requests no longer confirm webhook reachability with a `200` response. (`GHSA-mhxh-9pjm-w7q5`)(#44090) Thanks @TerminalsandCoffee and @vincentkoc. - Security/Zalo webhook: rate limit invalid secret guesses before auth so weak webhook secrets cannot be brute-forced through unauthenticated churned requests without pre-auth `429` responses. (`GHSA-5m9r-p9g7-679c`)(#44173) Thanks @zpbrent and @vincentkoc. - Security/Zalouser groups: require stable group IDs for allowlist auth by default and gate mutable group-name matching behind `channels.zalouser.dangerouslyAllowNameMatching`. Thanks @zpbrent. +- Security/Slack and Teams routing: require stable channel and team IDs for allowlist routing by default, with mutable name matching only via each channel's `dangerouslyAllowNameMatching` break-glass flag. - Security/exec approvals: fail closed for ambiguous inline loader and shell-payload script execution, bind the real script after POSIX shell value-taking flags, and unwrap `pnpm`/`npm exec`/`npx` script runners before approval binding. (`GHSA-57jw-9722-6rf2`)(`GHSA-jvqh-rfmh-jh27`)(`GHSA-x7pp-23xv-mmr4`)(`GHSA-jc5j-vg4r-j5jx`)(#44247) Thanks @tdjackey and @vincentkoc. - Doctor/gateway service audit: canonicalize service entrypoint paths before comparing them so symlink-vs-realpath installs no longer trigger false "entrypoint does not match the current install" repair prompts. (#43882) Thanks @ngutman. - Doctor/gateway service audit: earlier groundwork for this fix landed in the superseded #28338 branch. Thanks @realriphub. diff --git a/docs/channels/msteams.md b/docs/channels/msteams.md index 9c4a583e1b5..a24f20c69df 100644 --- a/docs/channels/msteams.md +++ b/docs/channels/msteams.md @@ -114,11 +114,11 @@ Example: **Teams + channel allowlist** - Scope group/channel replies by listing teams and channels under `channels.msteams.teams`. -- Keys can be team IDs or names; channel keys can be conversation IDs or names. +- Keys should use stable team IDs and channel conversation IDs. - When `groupPolicy="allowlist"` and a teams allowlist is present, only listed teams/channels are accepted (mention‑gated). - The configure wizard accepts `Team/Channel` entries and stores them for you. - On startup, OpenClaw resolves team/channel and user allowlist names to IDs (when Graph permissions allow) - and logs the mapping; unresolved entries are kept as typed. + and logs the mapping; unresolved team/channel names are kept as typed but ignored for routing by default unless `channels.msteams.dangerouslyAllowNameMatching: true` is enabled. Example: @@ -457,7 +457,7 @@ Key settings (see `/gateway/configuration` for shared channel patterns): - `channels.msteams.webhook.path` (default `/api/messages`) - `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing) - `channels.msteams.allowFrom`: DM allowlist (AAD object IDs recommended). The wizard resolves names to IDs during setup when Graph access is available. -- `channels.msteams.dangerouslyAllowNameMatching`: break-glass toggle to re-enable mutable UPN/display-name matching. +- `channels.msteams.dangerouslyAllowNameMatching`: break-glass toggle to re-enable mutable UPN/display-name matching and direct team/channel name routing. - `channels.msteams.textChunkLimit`: outbound text chunk size. - `channels.msteams.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. - `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains). diff --git a/docs/channels/slack.md b/docs/channels/slack.md index c099120c699..7fe44cc611b 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -169,15 +169,15 @@ For actions/directory reads, user token can be preferred when configured. For wr - `allowlist` - `disabled` - Channel allowlist lives under `channels.slack.channels`. + Channel allowlist lives under `channels.slack.channels` and should use stable channel IDs. Runtime note: if `channels.slack` is completely missing (env-only setup), runtime falls back to `groupPolicy="allowlist"` and logs a warning (even if `channels.defaults.groupPolicy` is set). Name/ID resolution: - channel allowlist entries and DM allowlist entries are resolved at startup when token access allows - - unresolved entries are kept as configured - - inbound authorization matching is ID-first by default; direct username/slug matching requires `channels.slack.dangerouslyAllowNameMatching: true` + - unresolved channel-name entries are kept as configured but ignored for routing by default + - inbound authorization and channel routing are ID-first by default; direct username/slug matching requires `channels.slack.dangerouslyAllowNameMatching: true` @@ -190,7 +190,7 @@ For actions/directory reads, user token can be preferred when configured. For wr - mention regex patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`) - implicit reply-to-bot thread behavior - Per-channel controls (`channels.slack.channels.`): + Per-channel controls (`channels.slack.channels.`; names only via startup resolution or `dangerouslyAllowNameMatching`): - `requireMention` - `users` (allowlist) diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index 6fe227537d3..fff243fb70c 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -175,6 +175,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { teamName, conversationId, channelName, + allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg), }); const senderGroupPolicy = resolveSenderScopedGroupPolicy({ groupPolicy, diff --git a/extensions/msteams/src/policy.test.ts b/extensions/msteams/src/policy.test.ts index 02d59a99723..091e22d1fd8 100644 --- a/extensions/msteams/src/policy.test.ts +++ b/extensions/msteams/src/policy.test.ts @@ -50,7 +50,7 @@ describe("msteams policy", () => { expect(res.allowed).toBe(false); }); - it("matches team and channel by name", () => { + it("blocks team and channel name matches by default", () => { const cfg: MSTeamsConfig = { teams: { "My Team": { @@ -69,6 +69,31 @@ describe("msteams policy", () => { conversationId: "ignored", }); + expect(res.teamConfig).toBeUndefined(); + expect(res.channelConfig).toBeUndefined(); + expect(res.allowed).toBe(false); + }); + + it("matches team and channel by name when dangerous name matching is enabled", () => { + const cfg: MSTeamsConfig = { + teams: { + "My Team": { + requireMention: true, + channels: { + "General Chat": { requireMention: false }, + }, + }, + }, + }; + + const res = resolveMSTeamsRouteConfig({ + cfg, + teamName: "My Team", + channelName: "General Chat", + conversationId: "ignored", + allowNameMatching: true, + }); + expect(res.teamConfig?.requireMention).toBe(true); expect(res.channelConfig?.requireMention).toBe(false); expect(res.allowed).toBe(true); diff --git a/extensions/msteams/src/policy.ts b/extensions/msteams/src/policy.ts index 3d405f94c9e..c6317184d89 100644 --- a/extensions/msteams/src/policy.ts +++ b/extensions/msteams/src/policy.ts @@ -16,6 +16,7 @@ import { resolveToolsBySender, resolveChannelEntryMatchWithFallback, resolveNestedAllowlistDecision, + isDangerousNameMatchingEnabled, } from "openclaw/plugin-sdk/msteams"; export type MSTeamsResolvedRouteConfig = { @@ -35,6 +36,7 @@ export function resolveMSTeamsRouteConfig(params: { teamName?: string | null | undefined; conversationId?: string | null | undefined; channelName?: string | null | undefined; + allowNameMatching?: boolean; }): MSTeamsResolvedRouteConfig { const teamId = params.teamId?.trim(); const teamName = params.teamName?.trim(); @@ -44,8 +46,8 @@ export function resolveMSTeamsRouteConfig(params: { const allowlistConfigured = Object.keys(teams).length > 0; const teamCandidates = buildChannelKeyCandidates( teamId, - teamName, - teamName ? normalizeChannelSlug(teamName) : undefined, + params.allowNameMatching ? teamName : undefined, + params.allowNameMatching && teamName ? normalizeChannelSlug(teamName) : undefined, ); const teamMatch = resolveChannelEntryMatchWithFallback({ entries: teams, @@ -58,8 +60,8 @@ export function resolveMSTeamsRouteConfig(params: { const channelAllowlistConfigured = Object.keys(channels).length > 0; const channelCandidates = buildChannelKeyCandidates( conversationId, - channelName, - channelName ? normalizeChannelSlug(channelName) : undefined, + params.allowNameMatching ? channelName : undefined, + params.allowNameMatching && channelName ? normalizeChannelSlug(channelName) : undefined, ); const channelMatch = resolveChannelEntryMatchWithFallback({ entries: channels, @@ -101,6 +103,7 @@ export function resolveMSTeamsGroupToolPolicy( const groupId = params.groupId?.trim(); const groupChannel = params.groupChannel?.trim(); const groupSpace = params.groupSpace?.trim(); + const allowNameMatching = isDangerousNameMatchingEnabled(cfg); const resolved = resolveMSTeamsRouteConfig({ cfg, @@ -108,6 +111,7 @@ export function resolveMSTeamsGroupToolPolicy( teamName: groupSpace, conversationId: groupId, channelName: groupChannel, + allowNameMatching, }); if (resolved.channelConfig) { @@ -158,8 +162,8 @@ export function resolveMSTeamsGroupToolPolicy( const channelCandidates = buildChannelKeyCandidates( groupId, - groupChannel, - groupChannel ? normalizeChannelSlug(groupChannel) : undefined, + allowNameMatching ? groupChannel : undefined, + allowNameMatching && groupChannel ? normalizeChannelSlug(groupChannel) : undefined, ); for (const teamConfig of Object.values(cfg.teams ?? {})) { const match = resolveChannelEntryMatchWithFallback({ diff --git a/src/slack/monitor/auth.ts b/src/slack/monitor/auth.ts index 7667c4496e2..b303e6c6bad 100644 --- a/src/slack/monitor/auth.ts +++ b/src/slack/monitor/auth.ts @@ -256,6 +256,7 @@ export async function authorizeSlackSystemEventSender(params: { channels: params.ctx.channelsConfig, channelKeys: params.ctx.channelsConfigKeys, defaultRequireMention: params.ctx.defaultRequireMention, + allowNameMatching: params.ctx.allowNameMatching, }); const channelUsersAllowlistConfigured = Array.isArray(channelConfig?.users) && channelConfig.users.length > 0; diff --git a/src/slack/monitor/channel-config.ts b/src/slack/monitor/channel-config.ts index eaa8d1ae43a..88db84b33f4 100644 --- a/src/slack/monitor/channel-config.ts +++ b/src/slack/monitor/channel-config.ts @@ -91,8 +91,16 @@ export function resolveSlackChannelConfig(params: { channels?: SlackChannelConfigEntries; channelKeys?: string[]; defaultRequireMention?: boolean; + allowNameMatching?: boolean; }): SlackChannelConfigResolved | null { - const { channelId, channelName, channels, channelKeys, defaultRequireMention } = params; + const { + channelId, + channelName, + channels, + channelKeys, + defaultRequireMention, + allowNameMatching, + } = params; const entries = channels ?? {}; const keys = channelKeys ?? Object.keys(entries); const normalizedName = channelName ? normalizeSlackSlug(channelName) : ""; @@ -107,9 +115,9 @@ export function resolveSlackChannelConfig(params: { channelId, channelIdLower !== channelId ? channelIdLower : undefined, channelIdUpper !== channelId ? channelIdUpper : undefined, - channelName ? `#${directName}` : undefined, - directName, - normalizedName, + allowNameMatching ? (channelName ? `#${directName}` : undefined) : undefined, + allowNameMatching ? directName : undefined, + allowNameMatching ? normalizedName : undefined, ); const match = resolveChannelEntryMatchWithFallback({ entries, diff --git a/src/slack/monitor/context.ts b/src/slack/monitor/context.ts index 1d75af03650..fd8882e2827 100644 --- a/src/slack/monitor/context.ts +++ b/src/slack/monitor/context.ts @@ -324,6 +324,7 @@ export function createSlackMonitorContext(params: { channels: params.channelsConfig, channelKeys: channelsConfigKeys, defaultRequireMention, + allowNameMatching: params.allowNameMatching, }); const channelMatchMeta = formatAllowlistMatchMeta(channelConfig); const channelAllowed = channelConfig?.allowed !== false; diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index 564dce16fea..f0b3127e450 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -144,6 +144,7 @@ async function resolveSlackConversationContext(params: { channels: ctx.channelsConfig, channelKeys: ctx.channelsConfigKeys, defaultRequireMention: ctx.defaultRequireMention, + allowNameMatching: ctx.allowNameMatching, }) : null; const allowBots = diff --git a/src/slack/monitor/monitor.test.ts b/src/slack/monitor/monitor.test.ts index 748be0a212a..7e7dfd11129 100644 --- a/src/slack/monitor/monitor.test.ts +++ b/src/slack/monitor/monitor.test.ts @@ -81,6 +81,32 @@ describe("resolveSlackChannelConfig", () => { }); expect(res).toMatchObject({ allowed: true, requireMention: false }); }); + + it("blocks channel-name route matches by default", () => { + const res = resolveSlackChannelConfig({ + channelId: "C1", + channelName: "ops-room", + channels: { "ops-room": { allow: true, requireMention: false } }, + defaultRequireMention: true, + }); + expect(res).toMatchObject({ allowed: false, requireMention: true }); + }); + + it("allows channel-name route matches when dangerous name matching is enabled", () => { + const res = resolveSlackChannelConfig({ + channelId: "C1", + channelName: "ops-room", + channels: { "ops-room": { allow: true, requireMention: false } }, + defaultRequireMention: true, + allowNameMatching: true, + }); + expect(res).toMatchObject({ + allowed: true, + requireMention: false, + matchKey: "ops-room", + matchSource: "direct", + }); + }); }); const baseParams = () => ({ diff --git a/src/slack/monitor/slash.ts b/src/slack/monitor/slash.ts index ffb8ef6f6e5..7d3b1839deb 100644 --- a/src/slack/monitor/slash.ts +++ b/src/slack/monitor/slash.ts @@ -404,6 +404,7 @@ export async function registerSlackMonitorSlashCommands(params: { channels: ctx.channelsConfig, channelKeys: ctx.channelsConfigKeys, defaultRequireMention: ctx.defaultRequireMention, + allowNameMatching: ctx.allowNameMatching, }); if (ctx.useAccessGroups) { const channelAllowlistConfigured = (ctx.channelsConfigKeys?.length ?? 0) > 0;