Root cause
Authentication & access control introduced
msteams: fetch thread history via Graph API for channel replies (#51643)
How AI contributed: Direct introductionPublished Apr 2, 2026 in
openclaw/openclawAll 35 findings in this repository
Sender-allowlist bypass (CWE-863) in the OpenClaw MSTeams extension (npm openclaw <=2026.3.28): in extensions/msteams/src/monitor-handler/message-handler.ts, when handling channel-message replies, the handler fetches the full thread history via the Graph API (fetchThreadReplies(graphToken, groupId, conversationId, activity.replyToId)) and injects ALL messages (parent + replies) into the model context via formatThreadContext(allMessages, activity.id) WITHOUT applying the sender allowlist. Direct and group messages go through allowlist enforcement (dmAllowFrom/effectiveGroupAllowFrom matchers), but reply-thread history bypasses it, so senders not on the allowlist can feed replies into the agent's context (medium severity, VI:L/SI:L). Fixed in v2026.3.31 by filtering thread messages through resolveMSTeamsAllowlistMatch when groupPolicy === 'allowlist'.
Root cause
msteams: fetch thread history via Graph API for channel replies (#51643)
Fix
msteams: filter thread history by sender allowlist (#57723)
Fix by Jacob Tomlinson · no AI marker found
Case facts
@@ -28,6 +28,12 @@ import { } from "../attachments.js"; import type { StoredConversationReference } from "../conversation-store.js"; import { formatUnknownError } from "../errors.js";+import {+ fetchChannelMessage,+ fetchThreadReplies,+ formatThreadContext,+ resolveTeamGroupId,+} from "../graph-thread.js"; import { extractMSTeamsConversationMessageId, extractMSTeamsQuoteInfo,@@ -474,6 +480,29 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { }); const mediaPayload = buildMSTeamsMediaPayload(mediaList);++ // Fetch thread history when the message is a reply inside a Teams channel thread.+ // This is a best-effort enhancement; errors are logged and do not block the reply.+ let threadContext: string | undefined;+ if (activity.replyToId && isChannel && teamId) {+ try {+ const graphToken = await tokenProvider.getAccessToken("https://graph.microsoft.com");+ const groupId = await resolveTeamGroupId(graphToken, teamId);+ const [parentMsg, replies] = await Promise.all([+ fetchChannelMessage(graphToken, groupId, conversationId, activity.replyToId),+ fetchThreadReplies(graphToken, groupId, conversationId, activity.replyToId),+ ]);+ const allMessages = parentMsg ? [parentMsg, ...replies] : replies;+ const formatted = formatThreadContext(allMessages, activity.id);+ if (formatted) {+ threadContext = formatted;+ }+ } catch (err) {+ log.debug?.("failed to fetch thread history", { error: String(err) });+ // Graceful degradation: thread history is an optional enhancement.+ }+ }+ const envelopeFrom = isDirectMessage ? senderName : conversationType; const { storePath, envelopeOptions, previousTimestamp } = resolveInboundSessionEnvelopeContext({ cfg,@@ -518,9 +547,14 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { : undefined; const commandBody = text.trim(); + // Prepend thread history to the agent body so the agent has full thread context.+ const bodyForAgent = threadContext+ ? `[Thread history]\n${threadContext}\n[/Thread history]\n\n${rawBody}`+ : rawBody;+ const ctxPayload = core.channel.reply.finalizeInboundContext({ Body: combinedBody,- BodyForAgent: rawBody,+ BodyForAgent: bodyForAgent, InboundHistory: inboundHistory, RawBody: rawBody, CommandBody: commandBody,@@ -0,0 +1,142 @@+import { fetchGraphJson, type GraphResponse } from "./graph.js";++export type GraphThreadMessage = {+ id?: string;+ from?: {+ user?: { displayName?: string; id?: string };+ application?: { displayName?: string; id?: string };+ };+ body?: { content?: string; contentType?: string };+ createdDateTime?: string;+};++// TTL cache for team ID -> group GUID mapping.+const teamGroupIdCache = new Map<string, { groupId: string; expiresAt: number }>();+const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes++/**+ * Strip HTML tags from Teams message content, preserving @mention display names.+ * Teams wraps mentions in <at>Name</at> tags.+ */+export function stripHtmlFromTeamsMessage(html: string): string {+ // Preserve mention display names by replacing <at>Name</at> with @Name.+ let text = html.replace(/<at[^>]*>(.*?)<\/at>/gi, "@$1");+ // Strip remaining HTML tags.+ text = text.replace(/<[^>]*>/g, " ");+ // Decode common HTML entities.+ text = text+ .replace(/&/g, "&")+ .replace(/</g, "<")+ .replace(/>/g, ">")+ .replace(/"/g, '"')+ .replace(/'/g, "'")+ .replace(/ /g, " ");+ // Normalize whitespace.+ return text.replace(/\s+/g, " ").trim();+}++/**+ * Resolve the Azure AD group GUID for a Teams conversation team ID.+ * Results are cached with a TTL to avoid repeated Graph API calls.+ */+export async function resolveTeamGroupId(+ token: string,+ conversationTeamId: string,+): Promise<string> {+ const cached = teamGroupIdCache.get(conversationTeamId);+ if (cached && cached.expiresAt > Date.now()) {+ return cached.groupId;+ }++ // The team ID in channelData is typically the group ID itself for standard teams.+ // Validate by fetching /teams/{id} and returning the confirmed id.+ // Requires Team.ReadBasic.All permission; fall back to raw ID if missing.+ try {+ const path = `/teams/${encodeURIComponent(conversationTeamId)}?$select=id`;+ const team = await fetchGraphJson<{ id?: string }>({ token, path });+ const groupId = team.id ?? conversationTeamId;++ // Only cache when the Graph lookup succeeds — caching a fallback raw ID+ // can cause silent failures for the entire TTL if the ID is not a valid+ // Graph team GUID (e.g. Bot Framework conversation key).+ teamGroupIdCache.set(conversationTeamId, {+ groupId,+ expiresAt: Date.now() + CACHE_TTL_MS,+ });++ return groupId;+ } catch {+ // Fallback to raw team ID without caching so subsequent calls retry the+ // Graph lookup instead of using a potentially invalid cached value.+ return conversationTeamId;+ }+}++/**+ * Fetch a single channel message (the parent/root of a thread).+ * Returns undefined on error so callers can degrade gracefully.+ */+export async function fetchChannelMessage(+ token: string,+ groupId: string,+ channelId: string,+ messageId: string,+): Promise<GraphThreadMessage | undefined> {+ const path = `/teams/${encodeURIComponent(groupId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}?$select=id,from,body,createdDateTime`;+ try {+ return await fetchGraphJson<GraphThreadMessage>({ token, path });+ } catch {+ return undefined;@@ -0,0 +1,246 @@+import { beforeEach, describe, expect, it, vi } from "vitest";+import {+ _teamGroupIdCacheForTest,+ fetchChannelMessage,+ fetchThreadReplies,+ formatThreadContext,+ resolveTeamGroupId,+ stripHtmlFromTeamsMessage,+} from "./graph-thread.js";+import { fetchGraphJson } from "./graph.js";++vi.mock("./graph.js", () => ({+ fetchGraphJson: vi.fn(),+}));++describe("stripHtmlFromTeamsMessage", () => {+ it("preserves @mention display names from <at> tags", () => {+ expect(stripHtmlFromTeamsMessage("<at>Alice</at> hello")).toBe("@Alice hello");+ });++ it("strips other HTML tags", () => {+ expect(stripHtmlFromTeamsMessage("<p>Hello <b>world</b></p>")).toBe("Hello world");+ });++ it("decodes common HTML entities", () => {+ expect(stripHtmlFromTeamsMessage("& <b> "x" 'y' z")).toBe(+ "& <b> \"x\" 'y' z",+ );+ });++ it("normalizes multiple whitespace to single space", () => {+ expect(stripHtmlFromTeamsMessage("hello world")).toBe("hello world");+ });++ it("handles <at> tags with attributes", () => {+ expect(stripHtmlFromTeamsMessage('<at id="123">Bob</at> please review')).toBe(+ "@Bob please review",+ );+ });++ it("returns empty string for empty input", () => {+ expect(stripHtmlFromTeamsMessage("")).toBe("");+ });+});++describe("resolveTeamGroupId", () => {+ beforeEach(() => {+ vi.mocked(fetchGraphJson).mockReset();+ _teamGroupIdCacheForTest.clear();+ });++ it("fetches team id from Graph and caches it", async () => {+ vi.mocked(fetchGraphJson).mockResolvedValueOnce({ id: "group-guid-1" } as never);++ const result = await resolveTeamGroupId("tok", "team-123");+ expect(result).toBe("group-guid-1");+ expect(fetchGraphJson).toHaveBeenCalledWith({+ token: "tok",+ path: "/teams/team-123?$select=id",+ });+ });++ it("returns cached value without calling Graph again", async () => {+ vi.mocked(fetchGraphJson).mockResolvedValueOnce({ id: "group-guid-2" } as never);++ await resolveTeamGroupId("tok", "team-456");+ await resolveTeamGroupId("tok", "team-456");++ expect(fetchGraphJson).toHaveBeenCalledTimes(1);+ });++ it("falls back to conversationTeamId when Graph returns no id", async () => {+ vi.mocked(fetchGraphJson).mockResolvedValueOnce({} as never);++ const result = await resolveTeamGroupId("tok", "team-fallback");+ expect(result).toBe("team-fallback");+ });+});++describe("fetchChannelMessage", () => {+ beforeEach(() => {+ vi.mocked(fetchGraphJson).mockReset();+ });++ it("fetches the parent message with correct path", async () => {+ const mockMsg = { id: "msg-1", body: { content: "hello", contentType: "text" } };+ vi.mocked(fetchGraphJson).mockResolvedValueOnce(mockMsg as never);++ const result = await fetchChannelMessage("tok", "group-1", "channel-1", "msg-1");@@ -459,7 +459,18 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { fetchThreadReplies(graphToken, groupId, conversationId, activity.replyToId), ]); const allMessages = parentMsg ? [parentMsg, ...replies] : replies;- const formatted = formatThreadContext(allMessages, activity.id);+ const threadMessages =+ groupPolicy === "allowlist"+ ? allMessages.filter((msg) => {+ return resolveMSTeamsAllowlistMatch({+ allowFrom: effectiveGroupAllowFrom,+ senderId: msg.from?.user?.id ?? "",+ senderName: msg.from?.user?.displayName,+ allowNameMatching,+ }).allowed;+ })+ : allMessages;+ const formatted = formatThreadContext(threadMessages, activity.id); if (formatted) { threadContext = formatted; }@@ -1,15 +1,75 @@ import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "../../runtime-api.js";+import type { GraphThreadMessage } from "../graph-thread.js"; import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.js"; import { setMSTeamsRuntime } from "../runtime.js"; import { createMSTeamsMessageHandler } from "./message-handler.js"; +const runtimeApiMockState = vi.hoisted(() => ({+ dispatchReplyFromConfigWithSettledDispatcher: vi.fn(async (params: { ctxPayload: unknown }) => ({+ queuedFinal: false,+ counts: {},+ capturedCtxPayload: params.ctxPayload,+ })),+}));++const graphThreadMockState = vi.hoisted(() => ({+ resolveTeamGroupId: vi.fn(async () => "group-1"),+ fetchChannelMessage: vi.fn<+ (+ token: string,+ groupId: string,+ channelId: string,+ messageId: string,+ ) => Promise<GraphThreadMessage | undefined>+ >(async () => undefined),+ fetchThreadReplies: vi.fn<+ (+ token: string,+ groupId: string,+ channelId: string,+ messageId: string,+ limit?: number,+ ) => Promise<GraphThreadMessage[]>+ >(async () => []),+}));++vi.mock("../../runtime-api.js", async () => {+ const actual =+ await vi.importActual<typeof import("../../runtime-api.js")>("../../runtime-api.js");+ return {+ ...actual,+ dispatchReplyFromConfigWithSettledDispatcher:+ runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher,+ };+});++vi.mock("../graph-thread.js", async () => {+ const actual = await vi.importActual<typeof import("../graph-thread.js")>("../graph-thread.js");+ return {+ ...actual,+ resolveTeamGroupId: graphThreadMockState.resolveTeamGroupId,+ fetchChannelMessage: graphThreadMockState.fetchChannelMessage,+ fetchThreadReplies: graphThreadMockState.fetchThreadReplies,+ };+});++vi.mock("../reply-dispatcher.js", () => ({+ createMSTeamsReplyDispatcher: () => ({+ dispatcher: {},+ replyOptions: {},+ markDispatchIdle: vi.fn(),+ }),+}));+ describe("msteams monitor handler authz", () => { function createDeps(cfg: OpenClawConfig) { const readAllowFromStore = vi.fn(async () => ["attacker-aad"]); const upsertPairingRequest = vi.fn(async () => null);+ const recordInboundSession = vi.fn(async () => undefined); setMSTeamsRuntime({ logging: { shouldLogVerbose: () => false },+ system: { enqueueSystemEvent: vi.fn() }, channel: { debounce: { resolveInboundDebounceMs: () => 0,@@ -28,6 +88,20 @@ describe("msteams monitor handler authz", () => { text: { hasControlCommand: () => false, },+ routing: {+ resolveAgentRoute: ({ peer }: { peer: { kind: string; id: string } }) => ({+ sessionKey: `msteams:${peer.kind}:${peer.id}`,+ agentId: "default",+ accountId: "default",+ }),+ },+ reply: {+ formatAgentEnvelope: ({ body }: { body: string }) => body,+ finalizeInboundContext: <T extends Record<string, unknown>>(ctx: T) => ctx,AI-assisted change 5b4200b3786c6850f5fe98c64324113f35048575addad966d6b777f7b0a8f5f3 · Fix 12968efbfc621cd904c7294db0a6c692c4c01a17467a87ddc0381d3448a363d4