fix: harden msteams group access

This commit is contained in:
Peter Steinberger
2026-01-12 08:31:59 +00:00
parent 4d075a703e
commit 006e1352d8
12 changed files with 206 additions and 7 deletions

View File

@@ -65,6 +65,7 @@
- Models: normalize Gemini 3 Pro/Flash IDs to preview names for live model lookups. (#769) — thanks @steipete. - Models: normalize Gemini 3 Pro/Flash IDs to preview names for live model lookups. (#769) — thanks @steipete.
- CLI: fix guardCancel typing for configure prompts. (#769) — thanks @steipete. - CLI: fix guardCancel typing for configure prompts. (#769) — thanks @steipete.
- Providers: default groupPolicy to allowlist across providers and warn in doctor when groups are open. - Providers: default groupPolicy to allowlist across providers and warn in doctor when groups are open.
- MS Teams: add groupPolicy/groupAllowFrom gating for group chats and warn when groups are open.
- Gateway/WebChat: include handshake validation details in the WebSocket close reason for easier debugging; preserve close codes. - Gateway/WebChat: include handshake validation details in the WebSocket close reason for easier debugging; preserve close codes.
- Gateway/Auth: send invalid connect responses before closing the handshake; stabilize invalid-connect auth test. - Gateway/Auth: send invalid connect responses before closing the handshake; stabilize invalid-connect auth test.
- Gateway: tighten gateway listener detection. - Gateway: tighten gateway listener detection.

View File

@@ -1,11 +1,11 @@
--- ---
summary: "Group chat behavior across surfaces (WhatsApp/Telegram/Discord/Slack/Signal/iMessage)" summary: "Group chat behavior across surfaces (WhatsApp/Telegram/Discord/Slack/Signal/iMessage/Microsoft Teams)"
read_when: read_when:
- Changing group chat behavior or mention gating - Changing group chat behavior or mention gating
--- ---
# Groups # Groups
Clawdbot treats group chats consistently across surfaces: WhatsApp, Telegram, Discord, Slack, Signal, iMessage. Clawdbot treats group chats consistently across surfaces: WhatsApp, Telegram, Discord, Slack, Signal, iMessage, Microsoft Teams.
## Beginner intro (2 minutes) ## Beginner intro (2 minutes)
Clawdbot “lives” on your own messaging accounts. There is no separate WhatsApp bot user. Clawdbot “lives” on your own messaging accounts. There is no separate WhatsApp bot user.
@@ -15,7 +15,7 @@ Default behavior:
- Groups are restricted (`groupPolicy: "allowlist"`). - Groups are restricted (`groupPolicy: "allowlist"`).
- Replies require a mention unless you explicitly disable mention gating. - Replies require a mention unless you explicitly disable mention gating.
Translation: anyone in the group can trigger Clawdbot by mentioning it. Translation: allowlisted senders can trigger Clawdbot by mentioning it.
> TL;DR > TL;DR
> - **DM access** is controlled by `*.allowFrom`. > - **DM access** is controlled by `*.allowFrom`.
@@ -71,6 +71,10 @@ Control how group/room messages are handled per provider:
groupPolicy: "disabled", groupPolicy: "disabled",
groupAllowFrom: ["chat_id:123"] groupAllowFrom: ["chat_id:123"]
}, },
msteams: {
groupPolicy: "disabled",
groupAllowFrom: ["user@org.com"]
},
discord: { discord: {
groupPolicy: "allowlist", groupPolicy: "allowlist",
guilds: { guilds: {
@@ -92,7 +96,7 @@ Control how group/room messages are handled per provider:
Notes: Notes:
- `groupPolicy` is separate from mention-gating (which requires @mentions). - `groupPolicy` is separate from mention-gating (which requires @mentions).
- WhatsApp/Telegram/Signal/iMessage: use `groupAllowFrom` (fallback: explicit `allowFrom`). - WhatsApp/Telegram/Signal/iMessage/Microsoft Teams: use `groupAllowFrom` (fallback: explicit `allowFrom`).
- Discord: allowlist uses `discord.guilds.<id>.channels`. - Discord: allowlist uses `discord.guilds.<id>.channels`.
- Slack: allowlist uses `slack.channels`. - Slack: allowlist uses `slack.channels`.
- Group DMs are controlled separately (`discord.dm.*`, `slack.dm.*`). - Group DMs are controlled separately (`discord.dm.*`, `slack.dm.*`).

View File

@@ -529,6 +529,10 @@ Use `*.groupPolicy` to control whether group/room messages are accepted at all:
groupPolicy: "allowlist", groupPolicy: "allowlist",
groupAllowFrom: ["chat_id:123"] groupAllowFrom: ["chat_id:123"]
}, },
msteams: {
groupPolicy: "allowlist",
groupAllowFrom: ["user@org.com"]
},
discord: { discord: {
groupPolicy: "allowlist", groupPolicy: "allowlist",
guilds: { guilds: {
@@ -548,7 +552,7 @@ Notes:
- `"open"`: groups bypass allowlists; mention-gating still applies. - `"open"`: groups bypass allowlists; mention-gating still applies.
- `"disabled"`: block all group/room messages. - `"disabled"`: block all group/room messages.
- `"allowlist"`: only allow groups/rooms that match the configured allowlist. - `"allowlist"`: only allow groups/rooms that match the configured allowlist.
- WhatsApp/Telegram/Signal/iMessage use `groupAllowFrom` (fallback: explicit `allowFrom`). - WhatsApp/Telegram/Signal/iMessage/Microsoft Teams use `groupAllowFrom` (fallback: explicit `allowFrom`).
- Discord/Slack use channel allowlists (`discord.guilds.*.channels`, `slack.channels`). - Discord/Slack use channel allowlists (`discord.guilds.*.channels`, `slack.channels`).
- Group DMs (Discord/Slack) are still controlled by `dm.groupEnabled` + `dm.groupChannels`. - Group DMs (Discord/Slack) are still controlled by `dm.groupEnabled` + `dm.groupChannels`.
- Default is `groupPolicy: "allowlist"`; if no allowlist is configured, group messages are blocked. - Default is `groupPolicy: "allowlist"`; if no allowlist is configured, group messages are blocked.

View File

@@ -70,7 +70,7 @@ Clawdbot has two separate “who can trigger me?” layers:
- **Group allowlist** (provider-specific): which groups/channels/guilds the bot will accept messages from at all. - **Group allowlist** (provider-specific): which groups/channels/guilds the bot will accept messages from at all.
- Common patterns: - Common patterns:
- `whatsapp.groups`, `telegram.groups`, `imessage.groups`: per-group defaults like `requireMention`; when set, it also acts as a group allowlist (include `"*"` to keep allow-all behavior). - `whatsapp.groups`, `telegram.groups`, `imessage.groups`: per-group defaults like `requireMention`; when set, it also acts as a group allowlist (include `"*"` to keep allow-all behavior).
- `groupPolicy="allowlist"` + `groupAllowFrom`: restrict who can trigger the bot *inside* a group session (WhatsApp/Telegram/Signal/iMessage). - `groupPolicy="allowlist"` + `groupAllowFrom`: restrict who can trigger the bot *inside* a group session (WhatsApp/Telegram/Signal/iMessage/Microsoft Teams).
- `discord.guilds` / `slack.channels`: per-surface allowlists + mention defaults. - `discord.guilds` / `slack.channels`: per-surface allowlists + mention defaults.
Details: [Configuration](/gateway/configuration) and [Groups](/concepts/groups) Details: [Configuration](/gateway/configuration) and [Groups](/concepts/groups)

View File

@@ -30,12 +30,34 @@ Minimal config:
} }
} }
``` ```
Note: group chats are blocked by default (`msteams.groupPolicy: "allowlist"`). To allow group replies, set `msteams.groupAllowFrom` (or use `groupPolicy: "open"` to allow any member, mention-gated).
## Goals ## Goals
- Talk to Clawdbot via Teams DMs, group chats, or channels. - Talk to Clawdbot via Teams DMs, group chats, or channels.
- Keep routing deterministic: replies always go back to the provider they arrived on. - Keep routing deterministic: replies always go back to the provider they arrived on.
- Default to safe channel behavior (mentions required unless configured otherwise). - Default to safe channel behavior (mentions required unless configured otherwise).
## Access control (DMs + groups)
**DM access**
- Default: `msteams.dmPolicy = "pairing"`. Unknown senders are ignored until approved.
- `msteams.allowFrom` accepts AAD object IDs or UPNs.
**Group access**
- Default: `msteams.groupPolicy = "allowlist"` (blocked unless you add `groupAllowFrom`).
- `msteams.groupAllowFrom` controls which senders can trigger in group chats/channels (falls back to `msteams.allowFrom`).
- Set `groupPolicy: "open"` to allow any member (still mentiongated by default).
Example:
```json5
{
msteams: {
groupPolicy: "allowlist",
groupAllowFrom: ["user@org.com"]
}
}
```
## How it works ## How it works
1. Create an **Azure Bot** (App ID + secret + tenant ID). 1. Create an **Azure Bot** (App ID + secret + tenant ID).
2. Build a **Teams app package** that references the bot and includes the RSC permissions below. 2. Build a **Teams app package** that references the bot and includes the RSC permissions below.

View File

@@ -1481,6 +1481,16 @@ describe("legacy config detection", () => {
} }
}); });
it("defaults msteams.groupPolicy to allowlist when msteams section exists", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ msteams: {} });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.msteams?.groupPolicy).toBe("allowlist");
}
});
it("rejects unsafe executable config values", async () => { it("rejects unsafe executable config values", async () => {
vi.resetModules(); vi.resetModules();
const { validateConfigObject } = await import("./config.js"); const { validateConfigObject } = await import("./config.js");

View File

@@ -763,6 +763,15 @@ export type MSTeamsConfig = {
dmPolicy?: DmPolicy; dmPolicy?: DmPolicy;
/** Allowlist for DM senders (AAD object IDs or UPNs). */ /** Allowlist for DM senders (AAD object IDs or UPNs). */
allowFrom?: Array<string>; allowFrom?: Array<string>;
/** Optional allowlist for group/channel senders (AAD object IDs or UPNs). */
groupAllowFrom?: Array<string>;
/**
* Controls how group/channel messages are handled:
* - "open": groups bypass allowFrom; mention-gating applies
* - "disabled": block all group messages
* - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
*/
groupPolicy?: GroupPolicy;
/** Outbound text chunk size (chars). Default: 4000. */ /** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number; textChunkLimit?: number;
/** Merge streamed block replies before sending. */ /** Merge streamed block replies before sending. */

View File

@@ -619,6 +619,8 @@ const MSTeamsConfigSchema = z
.optional(), .optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"), dmPolicy: DmPolicySchema.optional().default("pairing"),
allowFrom: z.array(z.string()).optional(), allowFrom: z.array(z.string()).optional(),
groupAllowFrom: z.array(z.string()).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
textChunkLimit: z.number().int().positive().optional(), textChunkLimit: z.number().int().positive().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
mediaAllowHosts: z.array(z.string()).optional(), mediaAllowHosts: z.array(z.string()).optional(),

View File

@@ -39,6 +39,7 @@ import {
import type { MSTeamsAdapter } from "./messenger.js"; import type { MSTeamsAdapter } from "./messenger.js";
import type { MSTeamsMonitorLogger } from "./monitor-types.js"; import type { MSTeamsMonitorLogger } from "./monitor-types.js";
import { import {
isMSTeamsGroupAllowed,
resolveMSTeamsReplyPolicy, resolveMSTeamsReplyPolicy,
resolveMSTeamsRouteConfig, resolveMSTeamsRouteConfig,
} from "./policy.js"; } from "./policy.js";
@@ -176,6 +177,9 @@ function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const senderName = from.name ?? from.id; const senderName = from.name ?? from.id;
const senderId = from.aadObjectId ?? from.id; const senderId = from.aadObjectId ?? from.id;
const storedAllowFrom = await readProviderAllowFromStore("msteams").catch(
() => [],
);
// Check DM policy for direct messages // Check DM policy for direct messages
if (isDirectMessage && msteamsCfg) { if (isDirectMessage && msteamsCfg) {
@@ -189,7 +193,6 @@ function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
if (dmPolicy !== "open") { if (dmPolicy !== "open") {
// Check allowlist - look up from config and pairing store // Check allowlist - look up from config and pairing store
const storedAllowFrom = await readProviderAllowFromStore("msteams");
const effectiveAllowFrom = [ const effectiveAllowFrom = [
...allowFrom.map((v) => String(v).toLowerCase()), ...allowFrom.map((v) => String(v).toLowerCase()),
...storedAllowFrom, ...storedAllowFrom,
@@ -225,6 +228,49 @@ function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
} }
} }
if (!isDirectMessage && msteamsCfg) {
const groupPolicy = msteamsCfg.groupPolicy ?? "allowlist";
const groupAllowFrom =
msteamsCfg.groupAllowFrom ??
(msteamsCfg.allowFrom && msteamsCfg.allowFrom.length > 0
? msteamsCfg.allowFrom
: []);
const effectiveGroupAllowFrom = [
...groupAllowFrom.map((v) => String(v)),
...storedAllowFrom,
];
if (groupPolicy === "disabled") {
log.debug("dropping group message (groupPolicy: disabled)", {
conversationId,
});
return;
}
if (groupPolicy === "allowlist") {
if (effectiveGroupAllowFrom.length === 0) {
log.debug(
"dropping group message (groupPolicy: allowlist, no groupAllowFrom)",
{ conversationId },
);
return;
}
const allowed = isMSTeamsGroupAllowed({
groupPolicy,
allowFrom: effectiveGroupAllowFrom,
senderId,
senderName,
});
if (!allowed) {
log.debug("dropping group message (not in groupAllowFrom)", {
sender: senderId,
label: senderName,
});
return;
}
}
}
// Build conversation reference for proactive replies // Build conversation reference for proactive replies
const agent = activity.recipient; const agent = activity.recipient;
const teamId = activity.channelData?.team?.id; const teamId = activity.channelData?.team?.id;

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import type { MSTeamsConfig } from "../config/types.js"; import type { MSTeamsConfig } from "../config/types.js";
import { import {
isMSTeamsGroupAllowed,
resolveMSTeamsReplyPolicy, resolveMSTeamsReplyPolicy,
resolveMSTeamsRouteConfig, resolveMSTeamsRouteConfig,
} from "./policy.js"; } from "./policy.js";
@@ -96,4 +97,72 @@ describe("msteams policy", () => {
expect(policy).toEqual({ requireMention: false, replyStyle: "thread" }); expect(policy).toEqual({ requireMention: false, replyStyle: "thread" });
}); });
}); });
describe("isMSTeamsGroupAllowed", () => {
it("allows when policy is open", () => {
expect(
isMSTeamsGroupAllowed({
groupPolicy: "open",
allowFrom: [],
senderId: "user-id",
senderName: "User",
}),
).toBe(true);
});
it("blocks when policy is disabled", () => {
expect(
isMSTeamsGroupAllowed({
groupPolicy: "disabled",
allowFrom: ["user-id"],
senderId: "user-id",
senderName: "User",
}),
).toBe(false);
});
it("blocks allowlist when empty", () => {
expect(
isMSTeamsGroupAllowed({
groupPolicy: "allowlist",
allowFrom: [],
senderId: "user-id",
senderName: "User",
}),
).toBe(false);
});
it("allows allowlist when sender matches", () => {
expect(
isMSTeamsGroupAllowed({
groupPolicy: "allowlist",
allowFrom: ["User-Id"],
senderId: "user-id",
senderName: "User",
}),
).toBe(true);
});
it("allows allowlist when sender name matches", () => {
expect(
isMSTeamsGroupAllowed({
groupPolicy: "allowlist",
allowFrom: ["user"],
senderId: "other",
senderName: "User",
}),
).toBe(true);
});
it("allows allowlist wildcard", () => {
expect(
isMSTeamsGroupAllowed({
groupPolicy: "allowlist",
allowFrom: ["*"],
senderId: "other",
senderName: "User",
}),
).toBe(true);
});
});
}); });

View File

@@ -1,4 +1,5 @@
import type { import type {
GroupPolicy,
MSTeamsChannelConfig, MSTeamsChannelConfig,
MSTeamsConfig, MSTeamsConfig,
MSTeamsReplyStyle, MSTeamsReplyStyle,
@@ -56,3 +57,25 @@ export function resolveMSTeamsReplyPolicy(params: {
return { requireMention, replyStyle }; return { requireMention, replyStyle };
} }
export function isMSTeamsGroupAllowed(params: {
groupPolicy: GroupPolicy;
allowFrom: Array<string | number>;
senderId: string;
senderName?: string | null;
}): boolean {
const { groupPolicy } = params;
if (groupPolicy === "disabled") return false;
if (groupPolicy === "open") return true;
const allowFrom = params.allowFrom
.map((entry) => String(entry).trim().toLowerCase())
.filter(Boolean);
if (allowFrom.length === 0) return false;
if (allowFrom.includes("*")) return true;
const senderId = params.senderId.toLowerCase();
const senderName = params.senderName?.toLowerCase();
return (
allowFrom.includes(senderId) ||
(senderName ? allowFrom.includes(senderName) : false)
);
}

View File

@@ -80,6 +80,15 @@ export const msteamsPlugin: ProviderPlugin<ResolvedMSTeamsAccount> = {
.filter(Boolean) .filter(Boolean)
.map((entry) => entry.toLowerCase()), .map((entry) => entry.toLowerCase()),
}, },
security: {
collectWarnings: ({ cfg }) => {
const groupPolicy = cfg.msteams?.groupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
return [
`- MS Teams groups: groupPolicy="open" allows any member to trigger (mention-gated). Set msteams.groupPolicy="allowlist" + msteams.groupAllowFrom to restrict senders.`,
];
},
},
setup: { setup: {
resolveAccountId: () => DEFAULT_ACCOUNT_ID, resolveAccountId: () => DEFAULT_ACCOUNT_ID,
applyAccountConfig: ({ cfg }) => ({ applyAccountConfig: ({ cfg }) => ({