VYPR
Moderate severityNVD Advisory· Published Mar 18, 2026· Updated Mar 31, 2026

OpenClaw < 2026.2.22 BlueBubbles - Access Control Bypass via Empty allowFrom Configuration

CVE-2026-22170

Description

OpenClaw versions prior to 2026.2.22 with the optional BlueBubbles plugin contain an access control bypass vulnerability where empty allowFrom configuration causes dmPolicy pairing and allowlist restrictions to be ineffective. Remote attackers can send direct messages to BlueBubbles accounts by exploiting the misconfigured allowlist validation logic to bypass intended sender authorization checks.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.2.222026.2.22

Affected products

1

Patches

4
4540790cb624

refactor(bluebubbles): share dm/group access policy checks

https://github.com/openclaw/openclawPeter SteinbergerFeb 21, 2026via ghsa
4 files changed · +265 126
  • extensions/bluebubbles/src/monitor-processing.ts+94 125 modified
    @@ -5,6 +5,8 @@ import {
       logInboundDrop,
       logTypingFailure,
       resolveAckReaction,
    +  resolveDmGroupAccessDecision,
    +  resolveEffectiveAllowFromLists,
       resolveControlCommandGate,
       stripMarkdown,
     } from "openclaw/plugin-sdk";
    @@ -323,41 +325,50 @@ export async function processMessage(
     
       const dmPolicy = account.config.dmPolicy ?? "pairing";
       const groupPolicy = account.config.groupPolicy ?? "allowlist";
    -  const configAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
    -  const configGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) => String(entry));
       const storeAllowFrom = await core.channel.pairing
         .readAllowFromStore("bluebubbles")
         .catch(() => []);
    -  const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom]
    -    .map((entry) => String(entry).trim())
    -    .filter(Boolean);
    -  const effectiveGroupAllowFrom = [
    -    ...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom),
    -    ...storeAllowFrom,
    -  ]
    -    .map((entry) => String(entry).trim())
    -    .filter(Boolean);
    +  const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveEffectiveAllowFromLists({
    +    allowFrom: account.config.allowFrom,
    +    groupAllowFrom: account.config.groupAllowFrom,
    +    storeAllowFrom,
    +  });
       const groupAllowEntry = formatGroupAllowlistEntry({
         chatGuid: message.chatGuid,
         chatId: message.chatId ?? undefined,
         chatIdentifier: message.chatIdentifier ?? undefined,
       });
       const groupName = message.chatName?.trim() || undefined;
    +  const accessDecision = resolveDmGroupAccessDecision({
    +    isGroup,
    +    dmPolicy,
    +    groupPolicy,
    +    effectiveAllowFrom,
    +    effectiveGroupAllowFrom,
    +    isSenderAllowed: (allowFrom) =>
    +      isAllowedBlueBubblesSender({
    +        allowFrom,
    +        sender: message.senderId,
    +        chatId: message.chatId ?? undefined,
    +        chatGuid: message.chatGuid ?? undefined,
    +        chatIdentifier: message.chatIdentifier ?? undefined,
    +      }),
    +  });
     
    -  if (isGroup) {
    -    if (groupPolicy === "disabled") {
    -      logVerbose(core, runtime, "Blocked BlueBubbles group message (groupPolicy=disabled)");
    -      logGroupAllowlistHint({
    -        runtime,
    -        reason: "groupPolicy=disabled",
    -        entry: groupAllowEntry,
    -        chatName: groupName,
    -        accountId: account.accountId,
    -      });
    -      return;
    -    }
    -    if (groupPolicy === "allowlist") {
    -      if (effectiveGroupAllowFrom.length === 0) {
    +  if (accessDecision.decision !== "allow") {
    +    if (isGroup) {
    +      if (accessDecision.reason === "groupPolicy=disabled") {
    +        logVerbose(core, runtime, "Blocked BlueBubbles group message (groupPolicy=disabled)");
    +        logGroupAllowlistHint({
    +          runtime,
    +          reason: "groupPolicy=disabled",
    +          entry: groupAllowEntry,
    +          chatName: groupName,
    +          accountId: account.accountId,
    +        });
    +        return;
    +      }
    +      if (accessDecision.reason === "groupPolicy=allowlist (empty allowlist)") {
             logVerbose(core, runtime, "Blocked BlueBubbles group message (no allowlist)");
             logGroupAllowlistHint({
               runtime,
    @@ -368,14 +379,7 @@ export async function processMessage(
             });
             return;
           }
    -      const allowed = isAllowedBlueBubblesSender({
    -        allowFrom: effectiveGroupAllowFrom,
    -        sender: message.senderId,
    -        chatId: message.chatId ?? undefined,
    -        chatGuid: message.chatGuid ?? undefined,
    -        chatIdentifier: message.chatIdentifier ?? undefined,
    -      });
    -      if (!allowed) {
    +      if (accessDecision.reason === "groupPolicy=allowlist (not allowlisted)") {
             logVerbose(
               core,
               runtime,
    @@ -395,70 +399,60 @@ export async function processMessage(
             });
             return;
           }
    +      return;
         }
    -  } else {
    -    if (dmPolicy === "disabled") {
    +
    +    if (accessDecision.reason === "dmPolicy=disabled") {
           logVerbose(core, runtime, `Blocked BlueBubbles DM from ${message.senderId}`);
           logVerbose(core, runtime, `drop: dmPolicy disabled sender=${message.senderId}`);
           return;
         }
    -    if (dmPolicy !== "open") {
    -      const allowed = isAllowedBlueBubblesSender({
    -        allowFrom: effectiveAllowFrom,
    -        sender: message.senderId,
    -        chatId: message.chatId ?? undefined,
    -        chatGuid: message.chatGuid ?? undefined,
    -        chatIdentifier: message.chatIdentifier ?? undefined,
    +
    +    if (accessDecision.decision === "pairing") {
    +      const { code, created } = await core.channel.pairing.upsertPairingRequest({
    +        channel: "bluebubbles",
    +        id: message.senderId,
    +        meta: { name: message.senderName },
           });
    -      if (!allowed) {
    -        if (dmPolicy === "pairing") {
    -          const { code, created } = await core.channel.pairing.upsertPairingRequest({
    -            channel: "bluebubbles",
    -            id: message.senderId,
    -            meta: { name: message.senderName },
    -          });
    -          runtime.log?.(
    -            `[bluebubbles] pairing request sender=${message.senderId} created=${created}`,
    +      runtime.log?.(`[bluebubbles] pairing request sender=${message.senderId} created=${created}`);
    +      if (created) {
    +        logVerbose(core, runtime, `bluebubbles pairing request sender=${message.senderId}`);
    +        try {
    +          await sendMessageBlueBubbles(
    +            message.senderId,
    +            core.channel.pairing.buildPairingReply({
    +              channel: "bluebubbles",
    +              idLine: `Your BlueBubbles sender id: ${message.senderId}`,
    +              code,
    +            }),
    +            { cfg: config, accountId: account.accountId },
               );
    -          if (created) {
    -            logVerbose(core, runtime, `bluebubbles pairing request sender=${message.senderId}`);
    -            try {
    -              await sendMessageBlueBubbles(
    -                message.senderId,
    -                core.channel.pairing.buildPairingReply({
    -                  channel: "bluebubbles",
    -                  idLine: `Your BlueBubbles sender id: ${message.senderId}`,
    -                  code,
    -                }),
    -                { cfg: config, accountId: account.accountId },
    -              );
    -              statusSink?.({ lastOutboundAt: Date.now() });
    -            } catch (err) {
    -              logVerbose(
    -                core,
    -                runtime,
    -                `bluebubbles pairing reply failed for ${message.senderId}: ${String(err)}`,
    -              );
    -              runtime.error?.(
    -                `[bluebubbles] pairing reply failed sender=${message.senderId}: ${String(err)}`,
    -              );
    -            }
    -          }
    -        } else {
    +          statusSink?.({ lastOutboundAt: Date.now() });
    +        } catch (err) {
               logVerbose(
                 core,
                 runtime,
    -            `Blocked unauthorized BlueBubbles sender ${message.senderId} (dmPolicy=${dmPolicy})`,
    +            `bluebubbles pairing reply failed for ${message.senderId}: ${String(err)}`,
               );
    -          logVerbose(
    -            core,
    -            runtime,
    -            `drop: dm sender not allowed sender=${message.senderId} allowFrom=${effectiveAllowFrom.join(",")}`,
    +          runtime.error?.(
    +            `[bluebubbles] pairing reply failed sender=${message.senderId}: ${String(err)}`,
               );
             }
    -        return;
           }
    +      return;
         }
    +
    +    logVerbose(
    +      core,
    +      runtime,
    +      `Blocked unauthorized BlueBubbles sender ${message.senderId} (dmPolicy=${dmPolicy})`,
    +    );
    +    logVerbose(
    +      core,
    +      runtime,
    +      `drop: dm sender not allowed sender=${message.senderId} allowFrom=${effectiveAllowFrom.join(",")}`,
    +    );
    +    return;
       }
     
       const chatId = message.chatId ?? undefined;
    @@ -1106,56 +1100,31 @@ export async function processReaction(
     
       const dmPolicy = account.config.dmPolicy ?? "pairing";
       const groupPolicy = account.config.groupPolicy ?? "allowlist";
    -  const configAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
    -  const configGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) => String(entry));
       const storeAllowFrom = await core.channel.pairing
         .readAllowFromStore("bluebubbles")
         .catch(() => []);
    -  const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom]
    -    .map((entry) => String(entry).trim())
    -    .filter(Boolean);
    -  const effectiveGroupAllowFrom = [
    -    ...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom),
    -    ...storeAllowFrom,
    -  ]
    -    .map((entry) => String(entry).trim())
    -    .filter(Boolean);
    -
    -  if (reaction.isGroup) {
    -    if (groupPolicy === "disabled") {
    -      return;
    -    }
    -    if (groupPolicy === "allowlist") {
    -      if (effectiveGroupAllowFrom.length === 0) {
    -        return;
    -      }
    -      const allowed = isAllowedBlueBubblesSender({
    -        allowFrom: effectiveGroupAllowFrom,
    -        sender: reaction.senderId,
    -        chatId: reaction.chatId ?? undefined,
    -        chatGuid: reaction.chatGuid ?? undefined,
    -        chatIdentifier: reaction.chatIdentifier ?? undefined,
    -      });
    -      if (!allowed) {
    -        return;
    -      }
    -    }
    -  } else {
    -    if (dmPolicy === "disabled") {
    -      return;
    -    }
    -    if (dmPolicy !== "open") {
    -      const allowed = isAllowedBlueBubblesSender({
    -        allowFrom: effectiveAllowFrom,
    +  const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveEffectiveAllowFromLists({
    +    allowFrom: account.config.allowFrom,
    +    groupAllowFrom: account.config.groupAllowFrom,
    +    storeAllowFrom,
    +  });
    +  const accessDecision = resolveDmGroupAccessDecision({
    +    isGroup: reaction.isGroup,
    +    dmPolicy,
    +    groupPolicy,
    +    effectiveAllowFrom,
    +    effectiveGroupAllowFrom,
    +    isSenderAllowed: (allowFrom) =>
    +      isAllowedBlueBubblesSender({
    +        allowFrom,
             sender: reaction.senderId,
             chatId: reaction.chatId ?? undefined,
             chatGuid: reaction.chatGuid ?? undefined,
             chatIdentifier: reaction.chatIdentifier ?? undefined,
    -      });
    -      if (!allowed) {
    -        return;
    -      }
    -    }
    +      }),
    +  });
    +  if (accessDecision.decision !== "allow") {
    +    return;
       }
     
       const chatId = reaction.chatId ?? undefined;
    
  • src/plugin-sdk/index.ts+5 0 modified
    @@ -310,6 +310,11 @@ export {
       readStringParam,
     } from "../agents/tools/common.js";
     export { formatDocsLink } from "../terminal/links.js";
    +export {
    +  resolveDmAllowState,
    +  resolveDmGroupAccessDecision,
    +  resolveEffectiveAllowFromLists,
    +} from "../security/dm-policy-shared.js";
     export type { HookEntry } from "../hooks/types.js";
     export { clamp, escapeRegExp, normalizeE164, safeParseJson, sleep } from "../utils.js";
     export { stripAnsi } from "../terminal/ansi.js";
    
  • src/security/dm-policy-shared.test.ts+95 1 modified
    @@ -1,5 +1,9 @@
     import { describe, expect, it } from "vitest";
    -import { resolveDmAllowState } from "./dm-policy-shared.js";
    +import {
    +  resolveDmAllowState,
    +  resolveDmGroupAccessDecision,
    +  resolveEffectiveAllowFromLists,
    +} from "./dm-policy-shared.js";
     
     describe("security/dm-policy-shared", () => {
       it("normalizes config + store allow entries and counts distinct senders", async () => {
    @@ -28,4 +32,94 @@ describe("security/dm-policy-shared", () => {
         expect(state.allowCount).toBe(0);
         expect(state.isMultiUserDm).toBe(false);
       });
    +
    +  it("builds effective DM/group allowlists from config + pairing store", () => {
    +    const lists = resolveEffectiveAllowFromLists({
    +      allowFrom: [" owner ", "", "owner2"],
    +      groupAllowFrom: ["group:abc"],
    +      storeAllowFrom: [" owner3 ", ""],
    +    });
    +    expect(lists.effectiveAllowFrom).toEqual(["owner", "owner2", "owner3"]);
    +    expect(lists.effectiveGroupAllowFrom).toEqual(["group:abc", "owner3"]);
    +  });
    +
    +  it("falls back to DM allowlist for groups when groupAllowFrom is empty", () => {
    +    const lists = resolveEffectiveAllowFromLists({
    +      allowFrom: [" owner "],
    +      groupAllowFrom: [],
    +      storeAllowFrom: [" owner2 "],
    +    });
    +    expect(lists.effectiveAllowFrom).toEqual(["owner", "owner2"]);
    +    expect(lists.effectiveGroupAllowFrom).toEqual(["owner", "owner2"]);
    +  });
    +
    +  const channels = [
    +    "bluebubbles",
    +    "imessage",
    +    "signal",
    +    "telegram",
    +    "whatsapp",
    +    "msteams",
    +    "matrix",
    +    "zalo",
    +  ] as const;
    +
    +  for (const channel of channels) {
    +    it(`[${channel}] blocks DM allowlist mode when allowlist is empty`, () => {
    +      const decision = resolveDmGroupAccessDecision({
    +        isGroup: false,
    +        dmPolicy: "allowlist",
    +        groupPolicy: "allowlist",
    +        effectiveAllowFrom: [],
    +        effectiveGroupAllowFrom: [],
    +        isSenderAllowed: () => false,
    +      });
    +      expect(decision).toEqual({
    +        decision: "block",
    +        reason: "dmPolicy=allowlist (not allowlisted)",
    +      });
    +    });
    +
    +    it(`[${channel}] uses pairing flow when DM sender is not allowlisted`, () => {
    +      const decision = resolveDmGroupAccessDecision({
    +        isGroup: false,
    +        dmPolicy: "pairing",
    +        groupPolicy: "allowlist",
    +        effectiveAllowFrom: [],
    +        effectiveGroupAllowFrom: [],
    +        isSenderAllowed: () => false,
    +      });
    +      expect(decision).toEqual({
    +        decision: "pairing",
    +        reason: "dmPolicy=pairing (not allowlisted)",
    +      });
    +    });
    +
    +    it(`[${channel}] allows DM sender when allowlisted`, () => {
    +      const decision = resolveDmGroupAccessDecision({
    +        isGroup: false,
    +        dmPolicy: "allowlist",
    +        groupPolicy: "allowlist",
    +        effectiveAllowFrom: ["owner"],
    +        effectiveGroupAllowFrom: [],
    +        isSenderAllowed: () => true,
    +      });
    +      expect(decision.decision).toBe("allow");
    +    });
    +
    +    it(`[${channel}] blocks group allowlist mode when sender/group is not allowlisted`, () => {
    +      const decision = resolveDmGroupAccessDecision({
    +        isGroup: true,
    +        dmPolicy: "pairing",
    +        groupPolicy: "allowlist",
    +        effectiveAllowFrom: ["owner"],
    +        effectiveGroupAllowFrom: ["group:abc"],
    +        isSenderAllowed: () => false,
    +      });
    +      expect(decision).toEqual({
    +        decision: "block",
    +        reason: "groupPolicy=allowlist (not allowlisted)",
    +      });
    +    });
    +  }
     });
    
  • src/security/dm-policy-shared.ts+71 0 modified
    @@ -2,6 +2,77 @@ import type { ChannelId } from "../channels/plugins/types.js";
     import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
     import { normalizeStringEntries } from "../shared/string-normalization.js";
     
    +export function resolveEffectiveAllowFromLists(params: {
    +  allowFrom?: Array<string | number> | null;
    +  groupAllowFrom?: Array<string | number> | null;
    +  storeAllowFrom?: Array<string | number> | null;
    +}): {
    +  effectiveAllowFrom: string[];
    +  effectiveGroupAllowFrom: string[];
    +} {
    +  const configAllowFrom = normalizeStringEntries(
    +    Array.isArray(params.allowFrom) ? params.allowFrom : undefined,
    +  );
    +  const configGroupAllowFrom = normalizeStringEntries(
    +    Array.isArray(params.groupAllowFrom) ? params.groupAllowFrom : undefined,
    +  );
    +  const storeAllowFrom = normalizeStringEntries(
    +    Array.isArray(params.storeAllowFrom) ? params.storeAllowFrom : undefined,
    +  );
    +  const effectiveAllowFrom = normalizeStringEntries([...configAllowFrom, ...storeAllowFrom]);
    +  const groupBase = configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom;
    +  const effectiveGroupAllowFrom = normalizeStringEntries([...groupBase, ...storeAllowFrom]);
    +  return { effectiveAllowFrom, effectiveGroupAllowFrom };
    +}
    +
    +export type DmGroupAccessDecision = "allow" | "block" | "pairing";
    +
    +export function resolveDmGroupAccessDecision(params: {
    +  isGroup: boolean;
    +  dmPolicy?: string | null;
    +  groupPolicy?: string | null;
    +  effectiveAllowFrom: Array<string | number>;
    +  effectiveGroupAllowFrom: Array<string | number>;
    +  isSenderAllowed: (allowFrom: string[]) => boolean;
    +}): {
    +  decision: DmGroupAccessDecision;
    +  reason: string;
    +} {
    +  const dmPolicy = params.dmPolicy ?? "pairing";
    +  const groupPolicy = params.groupPolicy ?? "allowlist";
    +  const effectiveAllowFrom = normalizeStringEntries(params.effectiveAllowFrom);
    +  const effectiveGroupAllowFrom = normalizeStringEntries(params.effectiveGroupAllowFrom);
    +
    +  if (params.isGroup) {
    +    if (groupPolicy === "disabled") {
    +      return { decision: "block", reason: "groupPolicy=disabled" };
    +    }
    +    if (groupPolicy === "allowlist") {
    +      if (effectiveGroupAllowFrom.length === 0) {
    +        return { decision: "block", reason: "groupPolicy=allowlist (empty allowlist)" };
    +      }
    +      if (!params.isSenderAllowed(effectiveGroupAllowFrom)) {
    +        return { decision: "block", reason: "groupPolicy=allowlist (not allowlisted)" };
    +      }
    +    }
    +    return { decision: "allow", reason: `groupPolicy=${groupPolicy}` };
    +  }
    +
    +  if (dmPolicy === "disabled") {
    +    return { decision: "block", reason: "dmPolicy=disabled" };
    +  }
    +  if (dmPolicy === "open") {
    +    return { decision: "allow", reason: "dmPolicy=open" };
    +  }
    +  if (params.isSenderAllowed(effectiveAllowFrom)) {
    +    return { decision: "allow", reason: `dmPolicy=${dmPolicy} (allowlisted)` };
    +  }
    +  if (dmPolicy === "pairing") {
    +    return { decision: "pairing", reason: "dmPolicy=pairing (not allowlisted)" };
    +  }
    +  return { decision: "block", reason: `dmPolicy=${dmPolicy} (not allowlisted)` };
    +}
    +
     export async function resolveDmAllowState(params: {
       provider: ChannelId;
       allowFrom?: Array<string | number> | null;
    
51c0893673de

refactor(security): remove unused empty allowlist mode

https://github.com/openclaw/openclawPeter SteinbergerFeb 21, 2026via ghsa
2 files changed · +1 16
  • src/plugin-sdk/allow-from.test.ts+0 12 modified
    @@ -37,18 +37,6 @@ describe("isAllowedParsedChatSender", () => {
         expect(allowed).toBe(false);
       });
     
    -  it("can explicitly allow when allowFrom is empty", () => {
    -    const allowed = isAllowedParsedChatSender({
    -      allowFrom: [],
    -      sender: "+15551234567",
    -      emptyAllowFrom: "allow",
    -      normalizeSender: (sender) => sender,
    -      parseAllowTarget,
    -    });
    -
    -    expect(allowed).toBe(true);
    -  });
    -
       it("allows wildcard entries", () => {
         const allowed = isAllowedParsedChatSender({
           allowFrom: ["*"],
    
  • src/plugin-sdk/allow-from.ts+1 4 modified
    @@ -21,15 +21,12 @@ export function isAllowedParsedChatSender<TParsed extends ParsedChatAllowTarget>
       chatId?: number | null;
       chatGuid?: string | null;
       chatIdentifier?: string | null;
    -  emptyAllowFrom?: "deny" | "allow";
       normalizeSender: (sender: string) => string;
       parseAllowTarget: (entry: string) => TParsed;
     }): boolean {
       const allowFrom = params.allowFrom.map((entry) => String(entry).trim());
       if (allowFrom.length === 0) {
    -    // Fail closed by default. Callers can opt into legacy "empty = allow all"
    -    // behavior explicitly when a surface intentionally treats an empty list as open.
    -    return params.emptyAllowFrom === "allow";
    +    return false;
       }
       if (allowFrom.includes("*")) {
         return true;
    
2ba6de7eaad8

refactor(security): make empty allowlist behavior explicit

https://github.com/openclaw/openclawPeter SteinbergerFeb 21, 2026via ghsa
3 files changed · +35 1
  • extensions/bluebubbles/src/targets.test.ts+19 0 modified
    @@ -1,5 +1,6 @@
     import { describe, expect, it } from "vitest";
     import {
    +  isAllowedBlueBubblesSender,
       looksLikeBlueBubblesTargetId,
       normalizeBlueBubblesMessagingTarget,
       parseBlueBubblesTarget,
    @@ -181,3 +182,21 @@ describe("parseBlueBubblesAllowTarget", () => {
         });
       });
     });
    +
    +describe("isAllowedBlueBubblesSender", () => {
    +  it("denies when allowFrom is empty", () => {
    +    const allowed = isAllowedBlueBubblesSender({
    +      allowFrom: [],
    +      sender: "+15551234567",
    +    });
    +    expect(allowed).toBe(false);
    +  });
    +
    +  it("allows wildcard entries", () => {
    +    const allowed = isAllowedBlueBubblesSender({
    +      allowFrom: ["*"],
    +      sender: "+15551234567",
    +    });
    +    expect(allowed).toBe(true);
    +  });
    +});
    
  • src/plugin-sdk/allow-from.test.ts+12 0 modified
    @@ -37,6 +37,18 @@ describe("isAllowedParsedChatSender", () => {
         expect(allowed).toBe(false);
       });
     
    +  it("can explicitly allow when allowFrom is empty", () => {
    +    const allowed = isAllowedParsedChatSender({
    +      allowFrom: [],
    +      sender: "+15551234567",
    +      emptyAllowFrom: "allow",
    +      normalizeSender: (sender) => sender,
    +      parseAllowTarget,
    +    });
    +
    +    expect(allowed).toBe(true);
    +  });
    +
       it("allows wildcard entries", () => {
         const allowed = isAllowedParsedChatSender({
           allowFrom: ["*"],
    
  • src/plugin-sdk/allow-from.ts+4 1 modified
    @@ -21,12 +21,15 @@ export function isAllowedParsedChatSender<TParsed extends ParsedChatAllowTarget>
       chatId?: number | null;
       chatGuid?: string | null;
       chatIdentifier?: string | null;
    +  emptyAllowFrom?: "deny" | "allow";
       normalizeSender: (sender: string) => string;
       parseAllowTarget: (entry: string) => TParsed;
     }): boolean {
       const allowFrom = params.allowFrom.map((entry) => String(entry).trim());
       if (allowFrom.length === 0) {
    -    return false;
    +    // Fail closed by default. Callers can opt into legacy "empty = allow all"
    +    // behavior explicitly when a surface intentionally treats an empty list as open.
    +    return params.emptyAllowFrom === "allow";
       }
       if (allowFrom.includes("*")) {
         return true;
    
9632b9bcf032

fix(security): fail closed parsed chat allowlist

https://github.com/openclaw/openclawPeter SteinbergerFeb 21, 2026via ghsa
5 files changed · +199 5
  • CHANGELOG.md+1 0 modified
    @@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai
     - Chat/Usage/TUI: strip synthetic inbound metadata blocks (including `Conversation info` and trailing `Untrusted context` channel metadata wrappers) from displayed conversation history so internal prompt context no longer leaks into user-visible logs.
     - Security/Exec: in non-default setups that manually add `sort` to `tools.exec.safeBins`, block `sort --compress-program` so allowlist-mode safe-bin checks cannot bypass approval. Thanks @tdjackey for reporting.
     - Security/Discord: add `openclaw security audit` warnings for name/tag-based Discord allowlist entries (DM allowlists, guild/channel `users`, and pairing-store entries), highlighting slug-collision risk while keeping name-based matching supported. Thanks @tdjackey for reporting.
    +- Security/BlueBubbles: make parsed chat allowlist checks fail closed when `allowFrom` is empty, restoring expected `pairing`/`allowlist` DM gating for BlueBubbles and blocking unauthorized DM/reaction processing when no allowlist entries are configured. This ships in the next npm release. Thanks @tdjackey for reporting.
     - Doctor/State integrity: only require/create the OAuth credentials directory when WhatsApp or pairing-backed channels are configured, and downgrade fresh-install missing-dir noise to an informational warning.
     - Agents/Sanitization: stop rewriting billing-shaped assistant text outside explicit error context so normal replies about billing/credits/payment are preserved across messaging channels. (#17834, fixes #11359)
     - Security/Agents: cap embedded Pi runner outer retry loop with a higher profile-aware dynamic limit (32-160 attempts) and return an explicit `retry_limit` error payload when retries never converge, preventing unbounded internal retry cycles (`GHSA-76m6-pj3w-v7mf`).
    
  • extensions/bluebubbles/src/monitor.test.ts+116 4 modified
    @@ -1017,9 +1017,86 @@ describe("BlueBubbles webhook monitor", () => {
           expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
         });
     
    +    it("blocks DM when dmPolicy=allowlist and allowFrom is empty", async () => {
    +      const account = createMockAccount({
    +        dmPolicy: "allowlist",
    +        allowFrom: [],
    +      });
    +      const config: OpenClawConfig = {};
    +      const core = createMockRuntime();
    +      setBlueBubblesRuntime(core);
    +
    +      unregister = registerBlueBubblesWebhookTarget({
    +        account,
    +        config,
    +        runtime: { log: vi.fn(), error: vi.fn() },
    +        core,
    +        path: "/bluebubbles-webhook",
    +      });
    +
    +      const payload = {
    +        type: "new-message",
    +        data: {
    +          text: "hello from blocked sender",
    +          handle: { address: "+15551234567" },
    +          isGroup: false,
    +          isFromMe: false,
    +          guid: "msg-1",
    +          date: Date.now(),
    +        },
    +      };
    +
    +      const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
    +      const res = createMockResponse();
    +
    +      await handleBlueBubblesWebhookRequest(req, res);
    +      await flushAsync();
    +
    +      expect(res.statusCode).toBe(200);
    +      expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
    +      expect(mockUpsertPairingRequest).not.toHaveBeenCalled();
    +    });
    +
    +    it("triggers pairing flow for unknown sender when dmPolicy=pairing and allowFrom is empty", async () => {
    +      const account = createMockAccount({
    +        dmPolicy: "pairing",
    +        allowFrom: [],
    +      });
    +      const config: OpenClawConfig = {};
    +      const core = createMockRuntime();
    +      setBlueBubblesRuntime(core);
    +
    +      unregister = registerBlueBubblesWebhookTarget({
    +        account,
    +        config,
    +        runtime: { log: vi.fn(), error: vi.fn() },
    +        core,
    +        path: "/bluebubbles-webhook",
    +      });
    +
    +      const payload = {
    +        type: "new-message",
    +        data: {
    +          text: "hello",
    +          handle: { address: "+15551234567" },
    +          isGroup: false,
    +          isFromMe: false,
    +          guid: "msg-1",
    +          date: Date.now(),
    +        },
    +      };
    +
    +      const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
    +      const res = createMockResponse();
    +
    +      await handleBlueBubblesWebhookRequest(req, res);
    +      await flushAsync();
    +
    +      expect(mockUpsertPairingRequest).toHaveBeenCalled();
    +      expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
    +    });
    +
         it("triggers pairing flow for unknown sender when dmPolicy=pairing", async () => {
    -      // Note: empty allowFrom = allow all. To trigger pairing, we need a non-empty
    -      // allowlist that doesn't include the sender
           const account = createMockAccount({
             dmPolicy: "pairing",
             allowFrom: ["+15559999999"], // Different number than sender
    @@ -1061,8 +1138,6 @@ describe("BlueBubbles webhook monitor", () => {
         it("does not resend pairing reply when request already exists", async () => {
           mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: false });
     
    -      // Note: empty allowFrom = allow all. To trigger pairing, we need a non-empty
    -      // allowlist that doesn't include the sender
           const account = createMockAccount({
             dmPolicy: "pairing",
             allowFrom: ["+15559999999"], // Different number than sender
    @@ -2627,6 +2702,43 @@ describe("BlueBubbles webhook monitor", () => {
       });
     
       describe("reaction events", () => {
    +    it("drops DM reactions when dmPolicy=pairing and allowFrom is empty", async () => {
    +      mockEnqueueSystemEvent.mockClear();
    +
    +      const account = createMockAccount({ dmPolicy: "pairing", allowFrom: [] });
    +      const config: OpenClawConfig = {};
    +      const core = createMockRuntime();
    +      setBlueBubblesRuntime(core);
    +
    +      unregister = registerBlueBubblesWebhookTarget({
    +        account,
    +        config,
    +        runtime: { log: vi.fn(), error: vi.fn() },
    +        core,
    +        path: "/bluebubbles-webhook",
    +      });
    +
    +      const payload = {
    +        type: "message-reaction",
    +        data: {
    +          handle: { address: "+15551234567" },
    +          isGroup: false,
    +          isFromMe: false,
    +          associatedMessageGuid: "msg-original-123",
    +          associatedMessageType: 2000,
    +          date: Date.now(),
    +        },
    +      };
    +
    +      const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
    +      const res = createMockResponse();
    +
    +      await handleBlueBubblesWebhookRequest(req, res);
    +      await flushAsync();
    +
    +      expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
    +    });
    +
         it("enqueues system event for reaction added", async () => {
           mockEnqueueSystemEvent.mockClear();
     
    
  • src/imessage/targets.test.ts+8 0 modified
    @@ -71,6 +71,14 @@ describe("imessage targets", () => {
         expect(ok).toBe(true);
       });
     
    +  it("denies when allowFrom is empty", () => {
    +    const ok = isAllowedIMessageSender({
    +      allowFrom: [],
    +      sender: "+1555",
    +    });
    +    expect(ok).toBe(false);
    +  });
    +
       it("formats chat targets", () => {
         expect(formatIMessageChatTarget(42)).toBe("chat_id:42");
         expect(formatIMessageChatTarget(undefined)).toBe("");
    
  • src/plugin-sdk/allow-from.test.ts+73 0 added
    @@ -0,0 +1,73 @@
    +import { describe, expect, it } from "vitest";
    +import { isAllowedParsedChatSender } from "./allow-from.js";
    +
    +function parseAllowTarget(
    +  entry: string,
    +):
    +  | { kind: "chat_id"; chatId: number }
    +  | { kind: "chat_guid"; chatGuid: string }
    +  | { kind: "chat_identifier"; chatIdentifier: string }
    +  | { kind: "handle"; handle: string } {
    +  const trimmed = entry.trim();
    +  const lower = trimmed.toLowerCase();
    +  if (lower.startsWith("chat_id:")) {
    +    return { kind: "chat_id", chatId: Number.parseInt(trimmed.slice("chat_id:".length), 10) };
    +  }
    +  if (lower.startsWith("chat_guid:")) {
    +    return { kind: "chat_guid", chatGuid: trimmed.slice("chat_guid:".length) };
    +  }
    +  if (lower.startsWith("chat_identifier:")) {
    +    return {
    +      kind: "chat_identifier",
    +      chatIdentifier: trimmed.slice("chat_identifier:".length),
    +    };
    +  }
    +  return { kind: "handle", handle: lower };
    +}
    +
    +describe("isAllowedParsedChatSender", () => {
    +  it("denies when allowFrom is empty", () => {
    +    const allowed = isAllowedParsedChatSender({
    +      allowFrom: [],
    +      sender: "+15551234567",
    +      normalizeSender: (sender) => sender,
    +      parseAllowTarget,
    +    });
    +
    +    expect(allowed).toBe(false);
    +  });
    +
    +  it("allows wildcard entries", () => {
    +    const allowed = isAllowedParsedChatSender({
    +      allowFrom: ["*"],
    +      sender: "user@example.com",
    +      normalizeSender: (sender) => sender.toLowerCase(),
    +      parseAllowTarget,
    +    });
    +
    +    expect(allowed).toBe(true);
    +  });
    +
    +  it("matches normalized handles", () => {
    +    const allowed = isAllowedParsedChatSender({
    +      allowFrom: ["User@Example.com"],
    +      sender: "user@example.com",
    +      normalizeSender: (sender) => sender.toLowerCase(),
    +      parseAllowTarget,
    +    });
    +
    +    expect(allowed).toBe(true);
    +  });
    +
    +  it("matches chat IDs when provided", () => {
    +    const allowed = isAllowedParsedChatSender({
    +      allowFrom: ["chat_id:42"],
    +      sender: "+15551234567",
    +      chatId: 42,
    +      normalizeSender: (sender) => sender,
    +      parseAllowTarget,
    +    });
    +
    +    expect(allowed).toBe(true);
    +  });
    +});
    
  • src/plugin-sdk/allow-from.ts+1 1 modified
    @@ -26,7 +26,7 @@ export function isAllowedParsedChatSender<TParsed extends ParsedChatAllowTarget>
     }): boolean {
       const allowFrom = params.allowFrom.map((entry) => String(entry).trim());
       if (allowFrom.length === 0) {
    -    return true;
    +    return false;
       }
       if (allowFrom.includes("*")) {
         return true;
    

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

8

News mentions

0

No linked articles in our index yet.