sudie-codes 897cda7d99
msteams: fix sender allowlist bypass when route allowlist is configured (GHSA-g7cr-9h7q-4qxq) (#49582)
When a route-level (teams/channel) allowlist was configured but the sender
allowlist (allowFrom/groupAllowFrom) was empty, resolveSenderScopedGroupPolicy
would downgrade the effective group policy from "allowlist" to "open", allowing
any Teams user to interact with the bot.

The fix: when channelGate.allowlistConfigured is true and effectiveGroupAllowFrom
is empty, preserve the configured groupPolicy ("allowlist") rather than letting
it be downgraded to "open". This ensures an empty sender allowlist with an active
route allowlist means deny-all rather than allow-all.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 10:08:19 -05:00

702 lines
23 KiB
TypeScript

import {
DEFAULT_ACCOUNT_ID,
buildPendingHistoryContextFromMap,
clearHistoryEntriesIfEnabled,
createChannelPairingController,
dispatchReplyFromConfigWithSettledDispatcher,
DEFAULT_GROUP_HISTORY_LIMIT,
logInboundDrop,
evaluateSenderGroupAccessForPolicy,
resolveSenderScopedGroupPolicy,
recordPendingHistoryEntryIfEnabled,
resolveDualTextControlCommandGate,
resolveDefaultGroupPolicy,
isDangerousNameMatchingEnabled,
readStoreAllowFromForDmPolicy,
resolveMentionGating,
resolveInboundSessionEnvelopeContext,
formatAllowlistMatchMeta,
resolveEffectiveAllowFromLists,
resolveDmGroupAccessWithLists,
type HistoryEntry,
} from "../../runtime-api.js";
import {
buildMSTeamsAttachmentPlaceholder,
buildMSTeamsMediaPayload,
type MSTeamsAttachmentLike,
summarizeMSTeamsHtmlAttachments,
} from "../attachments.js";
import type { StoredConversationReference } from "../conversation-store.js";
import { formatUnknownError } from "../errors.js";
import {
extractMSTeamsConversationMessageId,
normalizeMSTeamsConversationId,
parseMSTeamsActivityTimestamp,
stripMSTeamsMentionTags,
wasMSTeamsBotMentioned,
} from "../inbound.js";
import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.js";
import {
isMSTeamsGroupAllowed,
resolveMSTeamsAllowlistMatch,
resolveMSTeamsReplyPolicy,
resolveMSTeamsRouteConfig,
} from "../policy.js";
import { extractMSTeamsPollVote } from "../polls.js";
import { createMSTeamsReplyDispatcher } from "../reply-dispatcher.js";
import { getMSTeamsRuntime } from "../runtime.js";
import type { MSTeamsTurnContext } from "../sdk-types.js";
import { recordMSTeamsSentMessage, wasMSTeamsMessageSent } from "../sent-message-cache.js";
import { resolveMSTeamsInboundMedia } from "./inbound-media.js";
export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const {
cfg,
runtime,
appId,
adapter,
tokenProvider,
textLimit,
mediaMaxBytes,
conversationStore,
pollStore,
log,
} = deps;
const core = getMSTeamsRuntime();
const pairing = createChannelPairingController({
core,
channel: "msteams",
accountId: DEFAULT_ACCOUNT_ID,
});
const logVerboseMessage = (message: string) => {
if (core.logging.shouldLogVerbose()) {
log.debug?.(message);
}
};
const msteamsCfg = cfg.channels?.msteams;
const historyLimit = Math.max(
0,
msteamsCfg?.historyLimit ??
cfg.messages?.groupChat?.historyLimit ??
DEFAULT_GROUP_HISTORY_LIMIT,
);
const conversationHistories = new Map<string, HistoryEntry[]>();
const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({
cfg,
channel: "msteams",
});
type MSTeamsDebounceEntry = {
context: MSTeamsTurnContext;
rawText: string;
text: string;
attachments: MSTeamsAttachmentLike[];
wasMentioned: boolean;
implicitMention: boolean;
};
const handleTeamsMessageNow = async (params: MSTeamsDebounceEntry) => {
const context = params.context;
const activity = context.activity;
const rawText = params.rawText;
const text = params.text;
const attachments = params.attachments;
const attachmentPlaceholder = buildMSTeamsAttachmentPlaceholder(attachments);
const rawBody = text || attachmentPlaceholder;
const from = activity.from;
const conversation = activity.conversation;
const attachmentTypes = attachments
.map((att) => (typeof att.contentType === "string" ? att.contentType : undefined))
.filter(Boolean)
.slice(0, 3);
const htmlSummary = summarizeMSTeamsHtmlAttachments(attachments);
log.info("received message", {
rawText: rawText.slice(0, 50),
text: text.slice(0, 50),
attachments: attachments.length,
attachmentTypes,
from: from?.id,
conversation: conversation?.id,
});
if (htmlSummary) {
log.debug?.("html attachment summary", htmlSummary);
}
if (!from?.id) {
log.debug?.("skipping message without from.id");
return;
}
// Teams conversation.id may include ";messageid=..." suffix - strip it for session key.
const rawConversationId = conversation?.id ?? "";
const conversationId = normalizeMSTeamsConversationId(rawConversationId);
const conversationMessageId = extractMSTeamsConversationMessageId(rawConversationId);
const conversationType = conversation?.conversationType ?? "personal";
const isGroupChat = conversationType === "groupChat" || conversation?.isGroup === true;
const isChannel = conversationType === "channel";
const isDirectMessage = !isGroupChat && !isChannel;
const senderName = from.name ?? from.id;
const senderId = from.aadObjectId ?? from.id;
const dmPolicy = msteamsCfg?.dmPolicy ?? "pairing";
const storedAllowFrom = await readStoreAllowFromForDmPolicy({
provider: "msteams",
accountId: pairing.accountId,
dmPolicy,
readStore: pairing.readStoreForDmPolicy,
});
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
// Check DM policy for direct messages.
const dmAllowFrom = msteamsCfg?.allowFrom ?? [];
const configuredDmAllowFrom = dmAllowFrom.map((v) => String(v));
const groupAllowFrom = msteamsCfg?.groupAllowFrom;
const resolvedAllowFromLists = resolveEffectiveAllowFromLists({
allowFrom: configuredDmAllowFrom,
groupAllowFrom,
storeAllowFrom: storedAllowFrom,
dmPolicy,
});
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const groupPolicy =
!isDirectMessage && msteamsCfg
? (msteamsCfg.groupPolicy ?? defaultGroupPolicy ?? "allowlist")
: "disabled";
const effectiveGroupAllowFrom = resolvedAllowFromLists.effectiveGroupAllowFrom;
const teamId = activity.channelData?.team?.id;
const teamName = activity.channelData?.team?.name;
const channelName = activity.channelData?.channel?.name;
const channelGate = resolveMSTeamsRouteConfig({
cfg: msteamsCfg,
teamId,
teamName,
conversationId,
channelName,
allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
});
// When a route-level (team/channel) allowlist is configured but the sender allowlist is
// empty, resolveSenderScopedGroupPolicy would otherwise downgrade the policy to "open",
// allowing any sender. To close this bypass (GHSA-g7cr-9h7q-4qxq), treat an empty sender
// allowlist as deny-all whenever the route allowlist is active.
const senderGroupPolicy =
channelGate.allowlistConfigured && effectiveGroupAllowFrom.length === 0
? groupPolicy
: resolveSenderScopedGroupPolicy({
groupPolicy,
groupAllowFrom: effectiveGroupAllowFrom,
});
const access = resolveDmGroupAccessWithLists({
isGroup: !isDirectMessage,
dmPolicy,
groupPolicy: senderGroupPolicy,
allowFrom: configuredDmAllowFrom,
groupAllowFrom,
storeAllowFrom: storedAllowFrom,
groupAllowFromFallbackToAllowFrom: false,
isSenderAllowed: (allowFrom) =>
resolveMSTeamsAllowlistMatch({
allowFrom,
senderId,
senderName,
allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
}).allowed,
});
const effectiveDmAllowFrom = access.effectiveAllowFrom;
if (isDirectMessage && msteamsCfg && access.decision !== "allow") {
if (access.reason === "dmPolicy=disabled") {
log.debug?.("dropping dm (dms disabled)");
return;
}
const allowMatch = resolveMSTeamsAllowlistMatch({
allowFrom: effectiveDmAllowFrom,
senderId,
senderName,
allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
});
if (access.decision === "pairing") {
const request = await pairing.upsertPairingRequest({
id: senderId,
meta: { name: senderName },
});
if (request) {
log.info("msteams pairing request created", {
sender: senderId,
label: senderName,
});
}
}
log.debug?.("dropping dm (not allowlisted)", {
sender: senderId,
label: senderName,
allowlistMatch: formatAllowlistMatchMeta(allowMatch),
});
return;
}
if (!isDirectMessage && msteamsCfg) {
if (channelGate.allowlistConfigured && !channelGate.allowed) {
log.debug?.("dropping group message (not in team/channel allowlist)", {
conversationId,
teamKey: channelGate.teamKey ?? "none",
channelKey: channelGate.channelKey ?? "none",
channelMatchKey: channelGate.channelMatchKey ?? "none",
channelMatchSource: channelGate.channelMatchSource ?? "none",
});
return;
}
const senderGroupAccess = evaluateSenderGroupAccessForPolicy({
groupPolicy,
groupAllowFrom: effectiveGroupAllowFrom,
senderId,
isSenderAllowed: (_senderId, allowFrom) =>
resolveMSTeamsAllowlistMatch({
allowFrom,
senderId,
senderName,
allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
}).allowed,
});
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "disabled") {
log.debug?.("dropping group message (groupPolicy: disabled)", {
conversationId,
});
return;
}
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "empty_allowlist") {
log.debug?.("dropping group message (groupPolicy: allowlist, no allowlist)", {
conversationId,
});
return;
}
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "sender_not_allowlisted") {
const allowMatch = resolveMSTeamsAllowlistMatch({
allowFrom: effectiveGroupAllowFrom,
senderId,
senderName,
allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
});
log.debug?.("dropping group message (not in groupAllowFrom)", {
sender: senderId,
label: senderName,
allowlistMatch: formatAllowlistMatchMeta(allowMatch),
});
return;
}
}
const commandDmAllowFrom = isDirectMessage ? effectiveDmAllowFrom : configuredDmAllowFrom;
const ownerAllowedForCommands = isMSTeamsGroupAllowed({
groupPolicy: "allowlist",
allowFrom: commandDmAllowFrom,
senderId,
senderName,
allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
});
const groupAllowedForCommands = isMSTeamsGroupAllowed({
groupPolicy: "allowlist",
allowFrom: effectiveGroupAllowFrom,
senderId,
senderName,
allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
});
const { commandAuthorized, shouldBlock } = resolveDualTextControlCommandGate({
useAccessGroups,
primaryConfigured: commandDmAllowFrom.length > 0,
primaryAllowed: ownerAllowedForCommands,
secondaryConfigured: effectiveGroupAllowFrom.length > 0,
secondaryAllowed: groupAllowedForCommands,
hasControlCommand: core.channel.text.hasControlCommand(text, cfg),
});
if (shouldBlock) {
logInboundDrop({
log: logVerboseMessage,
channel: "msteams",
reason: "control command (unauthorized)",
target: senderId,
});
return;
}
// Build conversation reference for proactive replies.
const agent = activity.recipient;
const conversationRef: StoredConversationReference = {
activityId: activity.id,
user: { id: from.id, name: from.name, aadObjectId: from.aadObjectId },
agent,
bot: agent ? { id: agent.id, name: agent.name } : undefined,
conversation: {
id: conversationId,
conversationType,
tenantId: conversation?.tenantId,
},
teamId,
channelId: activity.channelId,
serviceUrl: activity.serviceUrl,
locale: activity.locale,
};
conversationStore.upsert(conversationId, conversationRef).catch((err) => {
log.debug?.("failed to save conversation reference", {
error: formatUnknownError(err),
});
});
const pollVote = extractMSTeamsPollVote(activity);
if (pollVote) {
try {
const poll = await pollStore.recordVote({
pollId: pollVote.pollId,
voterId: senderId,
selections: pollVote.selections,
});
if (!poll) {
log.debug?.("poll vote ignored (poll not found)", {
pollId: pollVote.pollId,
});
} else {
log.info("recorded poll vote", {
pollId: pollVote.pollId,
voter: senderId,
selections: pollVote.selections,
});
}
} catch (err) {
log.error("failed to record poll vote", {
pollId: pollVote.pollId,
error: formatUnknownError(err),
});
}
return;
}
if (!rawBody) {
log.debug?.("skipping empty message after stripping mentions");
return;
}
const teamsFrom = isDirectMessage
? `msteams:${senderId}`
: isChannel
? `msteams:channel:${conversationId}`
: `msteams:group:${conversationId}`;
const teamsTo = isDirectMessage ? `user:${senderId}` : `conversation:${conversationId}`;
const route = core.channel.routing.resolveAgentRoute({
cfg,
channel: "msteams",
peer: {
kind: isDirectMessage ? "direct" : isChannel ? "channel" : "group",
id: isDirectMessage ? senderId : conversationId,
},
});
const preview = rawBody.replace(/\s+/g, " ").slice(0, 160);
const inboundLabel = isDirectMessage
? `Teams DM from ${senderName}`
: `Teams message in ${conversationType} from ${senderName}`;
core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
sessionKey: route.sessionKey,
contextKey: `msteams:message:${conversationId}:${activity.id ?? "unknown"}`,
});
const channelId = conversationId;
const { teamConfig, channelConfig } = channelGate;
const { requireMention, replyStyle } = resolveMSTeamsReplyPolicy({
isDirectMessage,
globalConfig: msteamsCfg,
teamConfig,
channelConfig,
});
const timestamp = parseMSTeamsActivityTimestamp(activity.timestamp);
if (!isDirectMessage) {
const mentionGate = resolveMentionGating({
requireMention: Boolean(requireMention),
canDetectMention: true,
wasMentioned: params.wasMentioned,
implicitMention: params.implicitMention,
shouldBypassMention: false,
});
const mentioned = mentionGate.effectiveWasMentioned;
if (requireMention && mentionGate.shouldSkip) {
log.debug?.("skipping message (mention required)", {
teamId,
channelId,
requireMention,
mentioned,
});
recordPendingHistoryEntryIfEnabled({
historyMap: conversationHistories,
historyKey: conversationId,
limit: historyLimit,
entry: {
sender: senderName,
body: rawBody,
timestamp: timestamp?.getTime(),
messageId: activity.id ?? undefined,
},
});
return;
}
}
const mediaList = await resolveMSTeamsInboundMedia({
attachments,
htmlSummary: htmlSummary ?? undefined,
maxBytes: mediaMaxBytes,
tokenProvider,
allowHosts: msteamsCfg?.mediaAllowHosts,
authAllowHosts: msteamsCfg?.mediaAuthAllowHosts,
conversationType,
conversationId,
conversationMessageId: conversationMessageId ?? undefined,
activity: {
id: activity.id,
replyToId: activity.replyToId,
channelData: activity.channelData,
},
log,
preserveFilenames: (cfg as { media?: { preserveFilenames?: boolean } }).media
?.preserveFilenames,
});
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
const envelopeFrom = isDirectMessage ? senderName : conversationType;
const { storePath, envelopeOptions, previousTimestamp } = resolveInboundSessionEnvelopeContext({
cfg,
agentId: route.agentId,
sessionKey: route.sessionKey,
});
const body = core.channel.reply.formatAgentEnvelope({
channel: "Teams",
from: envelopeFrom,
timestamp,
previousTimestamp,
envelope: envelopeOptions,
body: rawBody,
});
let combinedBody = body;
const isRoomish = !isDirectMessage;
const historyKey = isRoomish ? conversationId : undefined;
if (isRoomish && historyKey) {
combinedBody = buildPendingHistoryContextFromMap({
historyMap: conversationHistories,
historyKey,
limit: historyLimit,
currentMessage: combinedBody,
formatEntry: (entry) =>
core.channel.reply.formatAgentEnvelope({
channel: "Teams",
from: conversationType,
timestamp: entry.timestamp,
body: `${entry.sender}: ${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`,
envelope: envelopeOptions,
}),
});
}
const inboundHistory =
isRoomish && historyKey && historyLimit > 0
? (conversationHistories.get(historyKey) ?? []).map((entry) => ({
sender: entry.sender,
body: entry.body,
timestamp: entry.timestamp,
}))
: undefined;
const commandBody = text.trim();
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: combinedBody,
BodyForAgent: rawBody,
InboundHistory: inboundHistory,
RawBody: rawBody,
CommandBody: commandBody,
BodyForCommands: commandBody,
From: teamsFrom,
To: teamsTo,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isDirectMessage ? "direct" : isChannel ? "channel" : "group",
ConversationLabel: envelopeFrom,
GroupSubject: !isDirectMessage ? conversationType : undefined,
SenderName: senderName,
SenderId: senderId,
Provider: "msteams" as const,
Surface: "msteams" as const,
MessageSid: activity.id,
Timestamp: timestamp?.getTime() ?? Date.now(),
WasMentioned: isDirectMessage || params.wasMentioned || params.implicitMention,
CommandAuthorized: commandAuthorized,
OriginatingChannel: "msteams" as const,
OriginatingTo: teamsTo,
...mediaPayload,
});
await core.channel.session.recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
onRecordError: (err) => {
logVerboseMessage(`msteams: failed updating session meta: ${String(err)}`);
},
});
logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
const sharePointSiteId = msteamsCfg?.sharePointSiteId;
const { dispatcher, replyOptions, markDispatchIdle } = createMSTeamsReplyDispatcher({
cfg,
agentId: route.agentId,
accountId: route.accountId,
runtime,
log,
adapter,
appId,
conversationRef,
context,
replyStyle,
textLimit,
onSentMessageIds: (ids) => {
for (const id of ids) {
recordMSTeamsSentMessage(conversationId, id);
}
},
tokenProvider,
sharePointSiteId,
});
log.info("dispatching to agent", { sessionKey: route.sessionKey });
try {
const { queuedFinal, counts } = await dispatchReplyFromConfigWithSettledDispatcher({
cfg,
ctxPayload,
dispatcher,
onSettled: () => {
markDispatchIdle();
},
replyOptions,
});
log.info("dispatch complete", { queuedFinal, counts });
if (!queuedFinal) {
if (isRoomish && historyKey) {
clearHistoryEntriesIfEnabled({
historyMap: conversationHistories,
historyKey,
limit: historyLimit,
});
}
return;
}
const finalCount = counts.final;
logVerboseMessage(
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
);
if (isRoomish && historyKey) {
clearHistoryEntriesIfEnabled({
historyMap: conversationHistories,
historyKey,
limit: historyLimit,
});
}
} catch (err) {
log.error("dispatch failed", { error: String(err) });
runtime.error?.(`msteams dispatch failed: ${String(err)}`);
try {
await context.sendActivity(
`⚠️ Agent failed: ${err instanceof Error ? err.message : String(err)}`,
);
} catch {
// Best effort.
}
}
};
const inboundDebouncer = core.channel.debounce.createInboundDebouncer<MSTeamsDebounceEntry>({
debounceMs: inboundDebounceMs,
buildKey: (entry) => {
const conversationId = normalizeMSTeamsConversationId(
entry.context.activity.conversation?.id ?? "",
);
const senderId =
entry.context.activity.from?.aadObjectId ?? entry.context.activity.from?.id ?? "";
if (!senderId || !conversationId) {
return null;
}
return `msteams:${appId}:${conversationId}:${senderId}`;
},
shouldDebounce: (entry) => {
if (!entry.text.trim()) {
return false;
}
if (entry.attachments.length > 0) {
return false;
}
return !core.channel.text.hasControlCommand(entry.text, cfg);
},
onFlush: async (entries) => {
const last = entries.at(-1);
if (!last) {
return;
}
if (entries.length === 1) {
await handleTeamsMessageNow(last);
return;
}
const combinedText = entries
.map((entry) => entry.text)
.filter(Boolean)
.join("\n");
if (!combinedText.trim()) {
return;
}
const combinedRawText = entries
.map((entry) => entry.rawText)
.filter(Boolean)
.join("\n");
const wasMentioned = entries.some((entry) => entry.wasMentioned);
const implicitMention = entries.some((entry) => entry.implicitMention);
await handleTeamsMessageNow({
context: last.context,
rawText: combinedRawText,
text: combinedText,
attachments: [],
wasMentioned,
implicitMention,
});
},
onError: (err) => {
runtime.error?.(`msteams debounce flush failed: ${String(err)}`);
},
});
return async function handleTeamsMessage(context: MSTeamsTurnContext) {
const activity = context.activity;
const rawText = activity.text?.trim() ?? "";
const text = stripMSTeamsMentionTags(rawText);
const attachments = Array.isArray(activity.attachments)
? (activity.attachments as unknown as MSTeamsAttachmentLike[])
: [];
const wasMentioned = wasMSTeamsBotMentioned(activity);
const conversationId = normalizeMSTeamsConversationId(activity.conversation?.id ?? "");
const replyToId = activity.replyToId ?? undefined;
const implicitMention = Boolean(
conversationId && replyToId && wasMSTeamsMessageSent(conversationId, replyToId),
);
await inboundDebouncer.enqueue({
context,
rawText,
text,
attachments,
wasMentioned,
implicitMention,
});
};
}