VYPR
Moderate severityNVD Advisory· Published Mar 21, 2026· Updated Mar 23, 2026

OpenClaw < 2026.2.25 - Unauthorized Reaction Status Event Enqueue via Access Check Bypass

CVE-2026-32050

Description

OpenClaw versions prior to 2026.2.25 contain an access control vulnerability in signal reaction notification handling that allows unauthorized senders to enqueue status events before authorization checks are applied. Attackers can exploit the reaction-only event path in event-handler.ts to queue signal reaction status lines for sessions without proper DM or group access validation.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.2.252026.2.25

Affected products

1

Patches

1
2aa7842adeed

fix(signal): enforce auth before reaction notification enqueue

https://github.com/openclaw/openclawPeter SteinbergerFeb 25, 2026via ghsa
3 files changed · +110 28
  • CHANGELOG.md+1 0 modified
    @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
     
     ### Fixes
     
    +- Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under `dmPolicy`/`groupPolicy`; reaction notifications now require channel access checks first. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
     - Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy `oauth.json` onboarding path that exposed the PKCE verifier via OAuth `state`; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (`2026.2.25`). Thanks @zdi-disclosures for reporting.
     - Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting.
     - Security/Gateway: harden `agents.files` path handling to block out-of-workspace symlink targets for `agents.files.get`/`agents.files.set`, keep in-workspace symlink targets supported, and add gateway regression coverage for both blocked escapes and allowed in-workspace symlinks. Thanks @tdjackey for reporting.
    
  • src/signal/monitor/event-handler.ts+50 28 modified
    @@ -36,6 +36,10 @@ import {
       upsertChannelPairingRequest,
     } from "../../pairing/pairing-store.js";
     import { resolveAgentRoute } from "../../routing/resolve-route.js";
    +import {
    +  resolveDmGroupAccessDecision,
    +  resolveEffectiveAllowFromLists,
    +} from "../../security/dm-policy-shared.js";
     import { normalizeE164 } from "../../utils.js";
     import {
       formatSignalPairingIdLine,
    @@ -366,15 +370,45 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
         const quoteText = dataMessage?.quote?.text?.trim() ?? "";
         const hasBodyContent =
           Boolean(messageText || quoteText) || Boolean(!reaction && dataMessage?.attachments?.length);
    +    const senderDisplay = formatSignalSenderDisplay(sender);
    +    const storeAllowFrom =
    +      deps.dmPolicy === "allowlist"
    +        ? []
    +        : await readChannelAllowFromStore("signal").catch(() => []);
    +    const { effectiveAllowFrom: effectiveDmAllow } = resolveEffectiveAllowFromLists({
    +      allowFrom: deps.allowFrom,
    +      groupAllowFrom: deps.groupAllowFrom,
    +      storeAllowFrom,
    +      dmPolicy: deps.dmPolicy,
    +    });
    +    const effectiveGroupAllow = [...deps.groupAllowFrom, ...storeAllowFrom];
    +    const resolveAccessDecision = (isGroup: boolean) =>
    +      resolveDmGroupAccessDecision({
    +        isGroup,
    +        dmPolicy: deps.dmPolicy,
    +        groupPolicy: deps.groupPolicy,
    +        effectiveAllowFrom: effectiveDmAllow,
    +        effectiveGroupAllowFrom: effectiveGroupAllow,
    +        isSenderAllowed: (allowFrom) => isSignalSenderAllowed(sender, allowFrom),
    +      });
    +    const dmAccess = resolveAccessDecision(false);
    +    const dmAllowed = dmAccess.decision === "allow";
     
         if (reaction && !hasBodyContent) {
           if (reaction.isRemove) {
             return;
           } // Ignore reaction removals
           const emojiLabel = reaction.emoji?.trim() || "emoji";
    -      const senderDisplay = formatSignalSenderDisplay(sender);
           const senderName = envelope.sourceName ?? senderDisplay;
           logVerbose(`signal reaction: ${emojiLabel} from ${senderName}`);
    +      const groupId = reaction.groupInfo?.groupId ?? undefined;
    +      const groupName = reaction.groupInfo?.groupName ?? undefined;
    +      const isGroup = Boolean(groupId);
    +      const reactionAccess = resolveAccessDecision(isGroup);
    +      if (reactionAccess.decision !== "allow") {
    +        logVerbose(`Blocked signal reaction sender ${senderDisplay} (${reactionAccess.reason})`);
    +        return;
    +      }
           const targets = deps.resolveSignalReactionTargets(reaction);
           const shouldNotify = deps.shouldEmitSignalReactionNotification({
             mode: deps.reactionMode,
    @@ -387,9 +421,6 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
             return;
           }
     
    -      const groupId = reaction.groupInfo?.groupId ?? undefined;
    -      const groupName = reaction.groupInfo?.groupName ?? undefined;
    -      const isGroup = Boolean(groupId);
           const senderPeerId = resolveSignalPeerId(sender);
           const route = resolveAgentRoute({
             cfg: deps.cfg,
    @@ -430,7 +461,6 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
           return;
         }
     
    -    const senderDisplay = formatSignalSenderDisplay(sender);
         const senderRecipient = resolveSignalRecipient(sender);
         const senderPeerId = resolveSignalPeerId(sender);
         const senderAllowId = formatSignalSenderId(sender);
    @@ -441,20 +471,15 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
         const groupId = dataMessage.groupInfo?.groupId ?? undefined;
         const groupName = dataMessage.groupInfo?.groupName ?? undefined;
         const isGroup = Boolean(groupId);
    -    const storeAllowFrom =
    -      deps.dmPolicy === "allowlist"
    -        ? []
    -        : await readChannelAllowFromStore("signal").catch(() => []);
    -    const effectiveDmAllow = [...deps.allowFrom, ...storeAllowFrom];
    -    const effectiveGroupAllow = [...deps.groupAllowFrom, ...storeAllowFrom];
    -    const dmAllowed =
    -      deps.dmPolicy === "open" ? true : isSignalSenderAllowed(sender, effectiveDmAllow);
     
         if (!isGroup) {
    -      if (deps.dmPolicy === "disabled") {
    +      if (dmAccess.decision === "block") {
    +        if (deps.dmPolicy !== "disabled") {
    +          logVerbose(`Blocked signal sender ${senderDisplay} (dmPolicy=${deps.dmPolicy})`);
    +        }
             return;
           }
    -      if (!dmAllowed) {
    +      if (dmAccess.decision === "pairing") {
             if (deps.dmPolicy === "pairing") {
               const senderId = senderAllowId;
               const { code, created } = await upsertChannelPairingRequest({
    @@ -483,23 +508,20 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
                   logVerbose(`signal pairing reply failed for ${senderId}: ${String(err)}`);
                 }
               }
    -        } else {
    -          logVerbose(`Blocked signal sender ${senderDisplay} (dmPolicy=${deps.dmPolicy})`);
             }
             return;
           }
         }
    -    if (isGroup && deps.groupPolicy === "disabled") {
    -      logVerbose("Blocked signal group message (groupPolicy: disabled)");
    -      return;
    -    }
    -    if (isGroup && deps.groupPolicy === "allowlist") {
    -      if (effectiveGroupAllow.length === 0) {
    -        logVerbose("Blocked signal group message (groupPolicy: allowlist, no groupAllowFrom)");
    -        return;
    -      }
    -      if (!isSignalSenderAllowed(sender, effectiveGroupAllow)) {
    -        logVerbose(`Blocked signal group sender ${senderDisplay} (not in groupAllowFrom)`);
    +    if (isGroup) {
    +      const groupAccess = resolveAccessDecision(true);
    +      if (groupAccess.decision !== "allow") {
    +        if (groupAccess.reason === "groupPolicy=disabled") {
    +          logVerbose("Blocked signal group message (groupPolicy: disabled)");
    +        } else if (groupAccess.reason === "groupPolicy=allowlist (empty allowlist)") {
    +          logVerbose("Blocked signal group message (groupPolicy: allowlist, no groupAllowFrom)");
    +        } else {
    +          logVerbose(`Blocked signal group sender ${senderDisplay} (not in groupAllowFrom)`);
    +        }
             return;
           }
         }
    
  • src/signal/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts+59 0 modified
    @@ -378,6 +378,65 @@ describe("monitorSignalProvider tool results", () => {
         expect(events.some((text) => text.includes("Signal reaction added"))).toBe(true);
       });
     
    +  it("blocks reaction notifications from unauthorized senders when dmPolicy is allowlist", async () => {
    +    setReactionNotificationConfig("all", {
    +      dmPolicy: "allowlist",
    +      allowFrom: ["+15550007777"],
    +    });
    +    await receiveSingleEnvelope({
    +      ...makeBaseEnvelope(),
    +      reactionMessage: {
    +        emoji: "✅",
    +        targetAuthor: "+15550002222",
    +        targetSentTimestamp: 2,
    +      },
    +    });
    +
    +    const events = getDirectSignalEventsFor("+15550001111");
    +    expect(events.some((text) => text.includes("Signal reaction added"))).toBe(false);
    +    expect(sendMock).not.toHaveBeenCalled();
    +    expect(upsertPairingRequestMock).not.toHaveBeenCalled();
    +  });
    +
    +  it("blocks reaction notifications from unauthorized senders when dmPolicy is pairing", async () => {
    +    setReactionNotificationConfig("own", {
    +      dmPolicy: "pairing",
    +      allowFrom: [],
    +      account: "+15550009999",
    +    });
    +    await receiveSingleEnvelope({
    +      ...makeBaseEnvelope(),
    +      reactionMessage: {
    +        emoji: "✅",
    +        targetAuthor: "+15550009999",
    +        targetSentTimestamp: 2,
    +      },
    +    });
    +
    +    const events = getDirectSignalEventsFor("+15550001111");
    +    expect(events.some((text) => text.includes("Signal reaction added"))).toBe(false);
    +    expect(sendMock).not.toHaveBeenCalled();
    +    expect(upsertPairingRequestMock).not.toHaveBeenCalled();
    +  });
    +
    +  it("allows reaction notifications for allowlisted senders when dmPolicy is allowlist", async () => {
    +    setReactionNotificationConfig("all", {
    +      dmPolicy: "allowlist",
    +      allowFrom: ["+15550001111"],
    +    });
    +    await receiveSingleEnvelope({
    +      ...makeBaseEnvelope(),
    +      reactionMessage: {
    +        emoji: "✅",
    +        targetAuthor: "+15550002222",
    +        targetSentTimestamp: 2,
    +      },
    +    });
    +
    +    const events = getDirectSignalEventsFor("+15550001111");
    +    expect(events.some((text) => text.includes("Signal reaction added"))).toBe(true);
    +  });
    +
       it("notifies on own reactions when target includes uuid + phone", async () => {
         setReactionNotificationConfig("own", { account: "+15550002222" });
         await receiveSingleEnvelope({
    

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

5

News mentions

0

No linked articles in our index yet.