Root cause
Caller-supplied target bypasses allowlist
Because getMessageMSTeams builds the Graph path directly from to, a lower-trust sender or steered agent could read messages or metadata from excluded Teams conversations.
Provisional finding. Full causal and release verification remains incomplete.
The Matrix, Feishu and Google Chat read actions, written by humans, passed a caller-supplied target to the provider without checking the configured allowlist. The AI-written Teams read action, added in 2026.3.28, did the same, so a lower-trust sender or steered agent could read excluded conversations. Fixed in 2026.8.1.
AI-assisted change
A Claude Opus 4.6 co-authored change added the Teams read handlers; like the earlier human-written Matrix, Feishu, and Google Chat reads, they passed a caller-supplied target to the provider without an allowlist check.
Root cause
Because getMessageMSTeams builds the Graph path directly from to, a lower-trust sender or steered agent could read messages or metadata from excluded Teams conversations.
Fix
The repair validates the requesting account, current conversation, users, teams, and channels, and routes read-dependent actions through the shared dispatcher's provider gate.
Fix authorship not yet analyzed
Case facts
Cite this finding
Vibe Security Radar, "GHSA-G7FW-3GJP-G5HF in openclaw/openclaw," Georgia Tech SSLab, https://vibesecradar.com/cves/GHSA-G7FW-3GJP-G5HF/
@@ -0,0 +1,173 @@+import type { OpenClawConfig } from "../runtime-api.js";+import { deleteGraphRequest, fetchGraphJson, postGraphJson, resolveGraphToken } from "./graph.js";++type GraphMessageBody = {+ content?: string;+ contentType?: string;+};++type GraphMessageFrom = {+ user?: { id?: string; displayName?: string };+ application?: { id?: string; displayName?: string };+};++type GraphMessage = {+ id?: string;+ body?: GraphMessageBody;+ from?: GraphMessageFrom;+ createdDateTime?: string;+};++type GraphPinnedMessage = {+ id?: string;+ message?: GraphMessage;+};++type GraphPinnedMessagesResponse = {+ value?: GraphPinnedMessage[];+};++/**+ * Resolve the Graph API path prefix for a conversation.+ * If `to` contains "/" it's a `teamId/channelId` (channel path),+ * otherwise it's a chat ID.+ */+function resolveConversationPath(to: string): {+ kind: "chat" | "channel";+ basePath: string;+ chatId?: string;+ teamId?: string;+ channelId?: string;+} {+ const trimmed = to.trim();+ if (trimmed.includes("/")) {+ const [teamId, channelId] = trimmed.split("/", 2);+ return {+ kind: "channel",+ basePath: `/teams/${encodeURIComponent(teamId!)}/channels/${encodeURIComponent(channelId!)}`,+ teamId,+ channelId,+ };+ }+ return {+ kind: "chat",+ basePath: `/chats/${encodeURIComponent(trimmed)}`,+ chatId: trimmed,+ };+}++export type GetMessageMSTeamsParams = {+ cfg: OpenClawConfig;+ to: string;+ messageId: string;+};++export type GetMessageMSTeamsResult = {+ id: string;+ text: string | undefined;+ from: GraphMessageFrom | undefined;+ createdAt: string | undefined;+};++/**+ * Retrieve a single message by ID from a chat or channel via Graph API.+ */+export async function getMessageMSTeams(+ params: GetMessageMSTeamsParams,+): Promise<GetMessageMSTeamsResult> {+ const token = await resolveGraphToken(params.cfg);+ const { basePath } = resolveConversationPath(params.to);+ const path = `${basePath}/messages/${encodeURIComponent(params.messageId)}`;+ const msg = await fetchGraphJson<GraphMessage>({ token, path });+ return {+ id: msg.id ?? params.messageId,+ text: msg.body?.content,+ from: msg.from,+ createdAt: msg.createdDateTime,+ };+}+@@ -4,11 +4,418 @@ * Runs plugin-owned message actions from the shared agent tool with sender trust checks. */ import type { AgentToolResult } from "../../agents/runtime/index.js";-import { getChannelPlugin } from "./index.js";-import type { ChannelMessageActionContext } from "./types.public.js";+import { normalizeOptionalAccountId, normalizeAccountId } from "../../routing/account-id.js";+import { normalizeChatType, type ChatType } from "../chat-type.js";+import { normalizeConversationReadInvocationOrigin } from "./conversation-read-origin.js";+import { resolveChannelPluginRegistration } from "./registry.js";+import type {+ ChannelMessageActionContext,+ ChannelMessageActionName,+ ChannelPlugin,+} from "./types.js"; -function requiresTrustedRequesterSender(ctx: ChannelMessageActionContext): boolean {- const plugin = getChannelPlugin(ctx.channel);+const READ_DEPENDENT_ACTIONS = new Set<ChannelMessageActionName>([+ "poll-vote",+ "react",+ "reactions",+ "read",+ "edit",+ "unsend",+ "delete",+ "pin",+ "unpin",+ "list-pins",+ "permissions",+ "thread-list",+ "search",+ "sticker-search",+ "member-info",+ "role-info",+ "emoji-list",+ "channel-info",+ "channel-list",+ "voice-status",+ "event-list",+ "download-file",+]);++// These bundled adapters have host-reviewed provider-side current/configured+// gates. Other bundled adapters retain the exact-current compatibility limit.+const BUNDLED_CHANNELS_WITH_PROVIDER_READ_GATES = new Set([+ "discord",+ "feishu",+ "matrix",+ "msteams",+ "slack",+]);++type HostConversationTargetKind =+ | "user"+ | "channel"+ | "room"+ | "chat"+ | "group"+ | "dm"+ | "conversation";++type HostConversationTarget = {+ id: string;+ kind?: HostConversationTargetKind;+};++const HOST_TARGET_KIND_PREFIXES = new Set<HostConversationTargetKind>([+ "user",+ "channel",+ "room",+ "chat",+ "group",+ "dm",+ "conversation",+]);++function stripHostProviderPrefix(params: {+ value: string;+ channel: string;+ providerPrefixes?: readonly string[];+}): string {+ const prefixes = [params.channel, ...(params.providerPrefixes ?? [])]+ .map((prefix) => prefix.trim().toLowerCase())+ .filter(+ (prefix): prefix is string =>+ Boolean(prefix) && !HOST_TARGET_KIND_PREFIXES.has(prefix as HostConversationTargetKind),+ );+ const lowered = params.value.toLowerCase();+ const prefix = prefixes.find((candidate) => lowered.startsWith(`${candidate}:`));@@ -0,0 +1,416 @@+import { ToolAuthorizationError } from "openclaw/plugin-sdk/channel-actions";+import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";+import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";+import type { OpenClawConfig } from "../runtime-api.js";+import { isDangerousNameMatchingEnabled, resolveDefaultGroupPolicy } from "../runtime-api.js";+import { listChannelsForTeamWithPageInfo, resolveGraphToken } from "./graph.js";+import { resolveMSTeamsRouteConfig } from "./policy.js";+import {+ normalizeMSTeamsMessagingTarget,+ looksLikeMSTeamsConversationId,+ resolveMSTeamsChannelAllowlist,+ resolveMSTeamsTeamsConfig,+ resolveMSTeamsUserAllowlist,+} from "./resolve-allowlist.js";++type MSTeamsReadContext = Pick<+ ChannelMessageActionContext,+ "accountId" | "conversationReadOrigin" | "requesterAccountId" | "toolContext"+>;++function normalizeTarget(raw?: string | null): string {+ return raw ? (normalizeMSTeamsMessagingTarget(raw) ?? "") : "";+}++function sameAccount(ctx: MSTeamsReadContext): boolean {+ const requested = normalizeOptionalString(ctx.accountId) ?? "default";+ const requester = normalizeOptionalString(ctx.requesterAccountId);+ return requester !== undefined && requester === requested;+}++export function isCurrentMSTeamsReadTarget(params: {+ ctx: MSTeamsReadContext;+ target: string;+}): boolean {+ if (+ normalizeOptionalString(params.ctx.toolContext?.currentChannelProvider)?.toLowerCase() !==+ "msteams" ||+ !sameAccount(params.ctx)+ ) {+ return false;+ }+ const candidates = [+ params.ctx.toolContext?.currentChannelId,+ params.ctx.toolContext?.currentMessagingTarget,+ params.ctx.toolContext?.currentGraphChannelId,+ ];+ const target = normalizeTarget(params.target);+ return candidates.some((candidate) => normalizeTarget(candidate) === target);+}++function normalizeUserTarget(target: string): string {+ return target+ .replace(/^user:/i, "")+ .trim()+ .toLowerCase();+}++function isStableUserId(value: string): boolean {+ return /^[0-9a-f-]{16,}$/i.test(value);+}++async function resolveAllowedDmTarget(+ cfg: OpenClawConfig,+ target: string,+): Promise<string | undefined> {+ const teams = cfg.channels?.msteams;+ if (teams?.dmPolicy === "disabled") {+ return undefined;+ }+ const userId = normalizeUserTarget(target);+ if (!userId) {+ return undefined;+ }+ const allowFrom = teams?.allowFrom ?? [];+ const normalizedEntries = allowFrom.map((entry) =>+ normalizeUserTarget(entry.replace(/^(msteams|teams):/i, "")),+ );+ const allowAll = (teams?.dmPolicy ?? "pairing") === "open" || normalizedEntries.includes("*");+ if (isStableUserId(userId)) {+ return allowAll || normalizedEntries.some((entry) => entry === userId)+ ? `user:${userId}`+ : undefined;+ }+ if (!isDangerousNameMatchingEnabled(teams)) {+ return undefined;+ }+ try {+ const [resolvedTarget, ...resolvedEntries] = await resolveMSTeamsUserAllowlist({+ cfg,AI-assisted change e7732154b1dc7c2434869e2c3d97c8f5da6dee580527cc6c750658dbaee785bd · Fix 2b7ccda4e1ffb17704b5bfbea540e91f1da474ae2d47d463dc21064ad03a8a8c