fix(routing): require ids for slack and msteams allowlists

This commit is contained in:
Peter Steinberger 2026-03-13 01:43:48 +00:00
parent f36d8c09f1
commit de3e6a8c5b
12 changed files with 87 additions and 18 deletions

View File

@ -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.

View File

@ -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 (mentiongated).
- 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).

View File

@ -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`
</Tab>
@ -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.<id|name>`):
Per-channel controls (`channels.slack.channels.<id>`; names only via startup resolution or `dangerouslyAllowNameMatching`):
- `requireMention`
- `users` (allowlist)

View File

@ -175,6 +175,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
teamName,
conversationId,
channelName,
allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
});
const senderGroupPolicy = resolveSenderScopedGroupPolicy({
groupPolicy,

View File

@ -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);

View File

@ -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({

View File

@ -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;

View File

@ -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,

View File

@ -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;

View File

@ -144,6 +144,7 @@ async function resolveSlackConversationContext(params: {
channels: ctx.channelsConfig,
channelKeys: ctx.channelsConfigKeys,
defaultRequireMention: ctx.defaultRequireMention,
allowNameMatching: ctx.allowNameMatching,
})
: null;
const allowBots =

View File

@ -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 = () => ({

View File

@ -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;