Root cause
Authentication & access control
feat(matrix): add thread session isolation (#8241)
How AI contributed: New attack surfacePublished Apr 2, 2026 in
openclaw/openclawAll 35 findings in this repository
Summary Matrix thread root and reply context bypass sender allowlist Current Maintainer Triage · Status: open · Normalized severity: medium · Assessment: Real in shipped v2026.3.28 Matrix because fetched thread-root/reply context bypasses sender allowlists, with unreleased mainline filtering fix. Affected Packages / Versions · Package: openclaw (npm) · Latest published npm version: 2026.3.31 · Vulnerable version range: <=2026.3.28 · Patched versions: >= 2026.3.31 · First stable tag containing the fix: v2026.3.31 Fix Commit(s) · 8a563d603b70ef6338915f0527bee87282c3bad5 — 2026-03-31T17:09:03+01:00 OpenClaw thanks @AntAISecurityLab for reporting.
GHSA-rg8m-3943-vm6q aliases CVE-2026-41376. Parent of fbfe2f15 has thread session keys but does not fetch thread-root bodies. Member fbfe2f15 (Claude Opus 4.5) adds fetchEventSummary into ThreadStarterBody with no sender allowlist. Squash 49c60e90 (#8241) keeps the fetch; member/carrier handler blobs differ. reply-context.ts is later (#55056). Fix 8a563d60 adds shouldIncludeRoomContextSender for thread and reply. v2026.2.9 lacks the carrier. v2026.2.12 and v2026.3.28 contain the carrier without the filter. v2026.3.31 contains the fix. Member is not a tag ancestor.
Root cause
feat(matrix): add thread session isolation (#8241)
Fix
fix(matrix): filter fetched room context by sender allowlist (#58376)
Case facts
@@ -12,6 +12,7 @@ import { } from "openclaw/plugin-sdk"; import type { CoreConfig, MatrixRoomConfig, ReplyToMode } from "../../types.js"; import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js";+import { fetchEventSummary } from "../actions/summary.js"; import { formatPollAsText, isPollStartType,@@ -431,16 +432,65 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam isThreadRoot: false, // @vector-im/matrix-bot-sdk doesn't have this info readily available }); - const route = core.channel.routing.resolveAgentRoute({+ const baseRoute = core.channel.routing.resolveAgentRoute({ cfg, channel: "matrix", peer: { kind: isDirectMessage ? "direct" : "channel", id: isDirectMessage ? senderId : roomId, }, });++ const route = {+ ...baseRoute,+ sessionKey: threadRootId+ ? `${baseRoute.sessionKey}:thread:${threadRootId}`+ : baseRoute.sessionKey,+ };++ let threadStarterBody: string | undefined;+ let threadLabel: string | undefined;+ let parentSessionKey: string | undefined;++ if (threadRootId) {+ const existingSession = core.channel.session.readSessionUpdatedAt({+ storePath: core.channel.session.resolveStorePath(cfg.session?.store, {+ agentId: baseRoute.agentId,+ }),+ sessionKey: route.sessionKey,+ });++ if (existingSession === undefined) {+ try {+ const rootEvent = await fetchEventSummary(client, roomId, threadRootId);+ if (rootEvent?.body) {+ const rootSenderName = rootEvent.sender+ ? await getMemberDisplayName(roomId, rootEvent.sender)+ : undefined;++ threadStarterBody = core.channel.reply.formatAgentEnvelope({+ channel: "Matrix",+ from: rootSenderName ?? rootEvent.sender ?? "Unknown",+ timestamp: rootEvent.timestamp,+ envelope: core.channel.reply.resolveEnvelopeFormatOptions(cfg),+ body: rootEvent.body,+ });++ threadLabel = `Matrix thread in ${roomName ?? roomId}`;+ parentSessionKey = baseRoute.sessionKey;+ }+ } catch (err) {+ logVerboseMessage(+ `matrix: failed to fetch thread root ${threadRootId}: ${String(err)}`,+ );+ }+ }+ }+ const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);- const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;+ const textWithId = threadRootId+ ? `${bodyText}\n[matrix event id: ${messageId} room: ${roomId} thread: ${threadRootId}]`+ : `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`; const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { agentId: route.agentId, });@@ -467,7 +517,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam To: `room:${roomId}`, SessionKey: route.sessionKey, AccountId: route.accountId,- ChatType: isDirectMessage ? "direct" : "channel",+ ChatType: threadRootId ? "thread" : isDirectMessage ? "direct" : "channel", ConversationLabel: envelopeFrom, SenderName: senderName, SenderId: senderId,@@ -490,6 +540,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam CommandSource: "text" as const, OriginatingChannel: "matrix" as const, OriginatingTo: `room:${roomId}`,+ ThreadStarterBody: threadStarterBody,@@ -267,4 +267,87 @@ describe("createMatrixRoomMessageHandler inbound body formatting", () => { expect(getEvent).toHaveBeenCalledTimes(1); expect(getMemberDisplayName).toHaveBeenCalledTimes(2); });++ it("drops thread and reply context fetched from non-allowlisted room senders", async () => {+ const { handler, finalizeInboundContext } = createMatrixHandlerTestHarness({+ client: {+ getEvent: async () =>+ createMatrixTextMessageEvent({+ eventId: "$thread-root",+ sender: "@mallory:example.org",+ body: "Malicious root topic",+ }),+ },+ isDirectMessage: false,+ groupPolicy: "allowlist",+ groupAllowFrom: ["@alice:example.org"],+ roomsConfig: { "*": {} },+ getMemberDisplayName: async (_roomId, userId) =>+ userId === "@alice:example.org" ? "Alice" : "Mallory",+ });++ await handler(+ "!room:example.org",+ createMatrixTextMessageEvent({+ eventId: "$reply1",+ sender: "@alice:example.org",+ body: "@room follow up",+ relatesTo: {+ rel_type: "m.thread",+ event_id: "$thread-root",+ "m.in_reply_to": { event_id: "$thread-root" },+ },+ mentions: { room: true },+ }),+ );++ const finalized = vi.mocked(finalizeInboundContext).mock.calls.at(-1)?.[0] as {+ ReplyToBody?: string;+ ReplyToSender?: string;+ ThreadStarterBody?: string;+ };+ expect(finalized.ThreadStarterBody).toBeUndefined();+ expect(finalized.ReplyToBody).toBeUndefined();+ expect(finalized.ReplyToSender).toBeUndefined();+ });++ it("drops quoted reply context fetched from non-allowlisted room senders", async () => {+ const { handler, finalizeInboundContext } = createMatrixHandlerTestHarness({+ client: {+ getEvent: async () =>+ createMatrixTextMessageEvent({+ eventId: "$quoted",+ sender: "@mallory:example.org",+ body: "Quoted payload",+ }),+ },+ isDirectMessage: false,+ groupPolicy: "allowlist",+ groupAllowFrom: ["@alice:example.org"],+ roomsConfig: { "*": {} },+ replyToMode: "all",+ getMemberDisplayName: async (_roomId, userId) =>+ userId === "@alice:example.org" ? "Alice" : "Mallory",+ });++ await handler(+ "!room:example.org",+ createMatrixTextMessageEvent({+ eventId: "$reply1",+ sender: "@alice:example.org",+ body: "@room follow up",+ relatesTo: {+ "m.in_reply_to": { event_id: "$quoted" },+ },+ mentions: { room: true },+ }),+ );++ const finalized = vi.mocked(finalizeInboundContext).mock.calls.at(-1)?.[0] as {+ ReplyToBody?: string;+ ReplyToSender?: string;+ };+ expect(finalized.ReplyToBody).toBeUndefined();+ expect(finalized.ReplyToSender).toBeUndefined();+ }); });@@ -25,6 +25,7 @@ import { } from "../send.js"; import { resolveMatrixMonitorAccessState } from "./access-state.js"; import { resolveMatrixAckReactionConfig } from "./ack-config.js";+import { resolveMatrixAllowListMatch } from "./allowlist.js"; import type { MatrixInboundEventDeduper } from "./inbound-dedupe.js"; import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js"; import { downloadMatrixMedia } from "./media.js";@@ -471,6 +472,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam isRoom, }); const {+ effectiveGroupAllowFrom, effectiveRoomUsers, groupAllowConfigured, directAllowMatch,@@ -860,6 +862,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam triggerSnapshot, threadRootId: _threadRootId, thread,+ effectiveGroupAllowFrom,+ effectiveRoomUsers, }; }; const ingressResult =@@ -910,24 +914,62 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam triggerSnapshot, threadRootId: _threadRootId, thread,+ effectiveGroupAllowFrom,+ effectiveRoomUsers, } = resolvedIngressResult; // Keep the per-room ingress gate focused on ordering-sensitive state updates. // Prompt/session enrichment below can run concurrently after the history snapshot is fixed. const replyToEventId = resolveMatrixReplyToEventId(event.content as RoomMessageEventContent); const threadTarget = thread.threadId;- const threadContext = _threadRootId+ const shouldIncludeRoomContextSender = (contextSenderId?: string): boolean => {+ if (!isRoom || !contextSenderId) {+ return true;+ }+ if (effectiveRoomUsers.length > 0) {+ return resolveMatrixAllowListMatch({+ allowList: effectiveRoomUsers,+ userId: contextSenderId,+ }).allowed;+ }+ if (groupPolicy === "allowlist" && effectiveGroupAllowFrom.length > 0) {+ return resolveMatrixAllowListMatch({+ allowList: effectiveGroupAllowFrom,+ userId: contextSenderId,+ }).allowed;+ }+ return true;+ };+ let threadContext = _threadRootId ? await resolveThreadContext({ roomId, threadRootId: _threadRootId }) : undefined;- const replyContext =- replyToEventId && replyToEventId === _threadRootId && threadContext?.summary- ? {- replyToBody: threadContext.summary,- replyToSender: threadContext.senderLabel,- }- : replyToEventId- ? await resolveReplyContext({ roomId, eventId: replyToEventId })- : undefined;+ let threadContextBlockedByAllowlist = false;+ if (threadContext?.senderId && !shouldIncludeRoomContextSender(threadContext.senderId)) {+ logVerboseMessage("matrix: drop thread root context (sender allowlist)");+ threadContextBlockedByAllowlist = true;+ threadContext = undefined;+ }+ let replyContext: Awaited<ReturnType<typeof resolveReplyContext>> | undefined;+ if (replyToEventId && replyToEventId === _threadRootId && threadContextBlockedByAllowlist) {+ replyContext = undefined;+ } else if (replyToEventId && replyToEventId === _threadRootId && threadContext?.summary) {+ replyContext = {+ replyToBody: threadContext.summary,+ replyToSender: threadContext.senderLabel,+ replyToSenderId: threadContext.senderId,+ };+ } else {+ replyContext = replyToEventId+ ? await resolveReplyContext({ roomId, eventId: replyToEventId })+ : undefined;+ }+ if (+ replyContext?.replyToSenderId &&@@ -63,6 +63,7 @@ describe("matrix thread context", () => { }), ).resolves.toEqual({ threadStarterBody: "Matrix thread root $root from Alice:\nRoot topic",+ senderId: "@alice:example.org", senderLabel: "Alice", summary: "Root topic", });@@ -115,6 +116,7 @@ describe("matrix thread context", () => { }), ).resolves.toEqual({ threadStarterBody: "Matrix thread root $root from Alice:\nRecovered topic",+ senderId: "@alice:example.org", senderLabel: "Alice", summary: "Recovered topic", });AI-assisted change 92a74b58af66fc09d418e2a1122c6a2ceb614f7f461af2538c14707e73ab2554 · Fix 3099337f81b19d73350d461fa5967da21ca5ba2543ed53026856f08ba09b974d