VYPR
Medium severity5.4NVD Advisory· Published Apr 28, 2026· Updated May 1, 2026

CVE-2026-41376

CVE-2026-41376

Description

OpenClaw before 2026.3.31 contains an allowlist bypass vulnerability in Matrix thread root and reply context handling that fails to properly validate message senders. Attackers can fetch thread-root and reply context messages that should be filtered by sender allowlists, bypassing access controls.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.3.312026.3.31

Affected products

1
  • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*
    Range: <2026.3.31

Patches

1
8a563d603b70

fix(matrix): filter fetched room context by sender allowlist (#58376)

https://github.com/openclaw/openclawJacob TomlinsonMar 31, 2026via ghsa
6 files changed · +144 10
  • extensions/matrix/src/matrix/monitor/handler.body-for-agent.test.ts+83 0 modified
    @@ -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();
    +  });
     });
    
  • extensions/matrix/src/matrix/monitor/handler.ts+52 10 modified
    @@ -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 &&
    +        !shouldIncludeRoomContextSender(replyContext.replyToSenderId)
    +      ) {
    +        logVerboseMessage("matrix: drop reply context (sender allowlist)");
    +        replyContext = undefined;
    +      }
           const roomInfo = isRoom ? await getRoomInfo(roomId) : undefined;
           const roomName = roomInfo?.name;
           const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
    
  • extensions/matrix/src/matrix/monitor/reply-context.test.ts+3 0 modified
    @@ -100,6 +100,7 @@ describe("matrix reply context", () => {
         expect(result).toEqual({
           replyToBody: "This is the original message",
           replyToSender: "Alice",
    +      replyToSenderId: "@alice:example.org",
         });
     
         // Second call should use cache
    @@ -198,6 +199,7 @@ describe("matrix reply context", () => {
         expect(second).toEqual({
           replyToBody: "Recovered message",
           replyToSender: "Bob",
    +      replyToSenderId: "@bob:example.org",
         });
     
         expect(getEvent).toHaveBeenCalledTimes(2);
    @@ -231,6 +233,7 @@ describe("matrix reply context", () => {
         expect(result).toEqual({
           replyToBody: "Hello",
           replyToSender: "@charlie:example.org",
    +      replyToSenderId: "@charlie:example.org",
         });
       });
     
    
  • extensions/matrix/src/matrix/monitor/reply-context.ts+2 0 modified
    @@ -8,6 +8,7 @@ const MAX_REPLY_BODY_LENGTH = 500;
     export type MatrixReplyContext = {
       replyToBody?: string;
       replyToSender?: string;
    +  replyToSenderId?: string;
     };
     
     function truncateReplyBody(value: string): string {
    @@ -85,6 +86,7 @@ export function createMatrixReplyContextResolver(params: {
         return remember(cacheKey, {
           replyToBody,
           replyToSender: senderName ?? senderId,
    +      replyToSenderId: senderId,
         });
       };
     }
    
  • extensions/matrix/src/matrix/monitor/thread-context.test.ts+2 0 modified
    @@ -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",
         });
    
  • extensions/matrix/src/matrix/monitor/thread-context.ts+2 0 modified
    @@ -7,6 +7,7 @@ const MAX_THREAD_STARTER_BODY_LENGTH = 500;
     
     type MatrixThreadContext = {
       threadStarterBody?: string;
    +  senderId?: string;
       senderLabel?: string;
       summary?: string;
     };
    @@ -99,6 +100,7 @@ export function createMatrixThreadContextResolver(params: {
             senderName,
             summary,
           }),
    +      senderId,
           senderLabel,
           summary,
         });
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.