Low severityNVD Advisory· Published Mar 19, 2026· Updated Mar 19, 2026
OpenClaw < 2026.2.26 - Authorization Bypass via DM Pairing-Store Leakage in Signal Group Allowlist
CVE-2026-31991
Description
OpenClaw versions prior to 2026.2.26 contain an authorization bypass vulnerability where Signal group allowlist policy incorrectly accepts sender identities from DM pairing-store approvals. Attackers can exploit this boundary weakness by obtaining DM pairing approval to bypass group allowlist checks and gain unauthorized group access.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.2.26 | 2026.2.26 |
Affected products
1Patches
264de4b6d6ae8fix: enforce explicit group auth boundaries across channels
20 files changed · +611 −328
extensions/bluebubbles/src/monitor-processing.ts+6 −4 modified@@ -502,6 +502,7 @@ export async function processMessage( const dmPolicy = account.config.dmPolicy ?? "pairing"; const groupPolicy = account.config.groupPolicy ?? "allowlist"; + const configuredAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry)); const storeAllowFrom = await readStoreAllowFromForDmPolicy({ provider: "bluebubbles", dmPolicy, @@ -511,7 +512,7 @@ export async function processMessage( isGroup, dmPolicy, groupPolicy, - allowFrom: account.config.allowFrom, + allowFrom: configuredAllowFrom, groupAllowFrom: account.config.groupAllowFrom, storeAllowFrom, isSenderAllowed: (allowFrom) => @@ -666,10 +667,11 @@ export async function processMessage( // Command gating (parity with iMessage/WhatsApp) const useAccessGroups = config.commands?.useAccessGroups !== false; const hasControlCmd = core.channel.text.hasControlCommand(messageText, config); + const commandDmAllowFrom = isGroup ? configuredAllowFrom : effectiveAllowFrom; const ownerAllowedForCommands = - effectiveAllowFrom.length > 0 + commandDmAllowFrom.length > 0 ? isAllowedBlueBubblesSender({ - allowFrom: effectiveAllowFrom, + allowFrom: commandDmAllowFrom, sender: message.senderId, chatId: message.chatId ?? undefined, chatGuid: message.chatGuid ?? undefined, @@ -690,7 +692,7 @@ export async function processMessage( const commandGate = resolveControlCommandGate({ useAccessGroups, authorizers: [ - { configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands }, + { configured: commandDmAllowFrom.length > 0, allowed: ownerAllowedForCommands }, { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, ], allowTextCommands: true,
extensions/googlechat/src/monitor.ts+61 −35 modified@@ -15,6 +15,7 @@ import { warnMissingProviderGroupPolicyFallbackOnce, requestBodyErrorToText, resolveMentionGatingWithBypass, + resolveDmGroupAccessWithLists, } from "openclaw/plugin-sdk"; import { type ResolvedGoogleChatAccount } from "./accounts.js"; import { @@ -503,14 +504,33 @@ async function processMessageWithPipeline(params: { const dmPolicy = account.config.dm?.policy ?? "pairing"; const configAllowFrom = (account.config.dm?.allowFrom ?? []).map((v) => String(v)); + const normalizedGroupUsers = groupUsers.map((v) => String(v)); + const senderGroupPolicy = + groupPolicy === "disabled" + ? "disabled" + : normalizedGroupUsers.length > 0 + ? "allowlist" + : "open"; const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config); const storeAllowFrom = !isGroup && dmPolicy !== "allowlist" && (dmPolicy !== "open" || shouldComputeAuth) ? await core.channel.pairing.readAllowFromStore("googlechat").catch(() => []) : []; - const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom]; + const access = resolveDmGroupAccessWithLists({ + isGroup, + dmPolicy, + groupPolicy: senderGroupPolicy, + allowFrom: configAllowFrom, + groupAllowFrom: normalizedGroupUsers, + storeAllowFrom, + groupAllowFromFallbackToAllowFrom: false, + isSenderAllowed: (allowFrom) => + isSenderAllowed(senderId, senderEmail, allowFrom, allowNameMatching), + }); + const effectiveAllowFrom = access.effectiveAllowFrom; + const effectiveGroupAllowFrom = access.effectiveGroupAllowFrom; warnDeprecatedUsersEmailEntries(core, runtime, effectiveAllowFrom); - const commandAllowFrom = isGroup ? groupUsers.map((v) => String(v)) : effectiveAllowFrom; + const commandAllowFrom = isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom; const useAccessGroups = config.commands?.useAccessGroups !== false; const senderAllowedForCommands = isSenderAllowed( senderId, @@ -553,47 +573,53 @@ async function processMessageWithPipeline(params: { } } + if (isGroup && access.decision !== "allow") { + logVerbose( + core, + runtime, + `drop group message (sender policy blocked, reason=${access.reason}, space=${spaceId})`, + ); + return; + } + if (!isGroup) { - if (dmPolicy === "disabled" || account.config.dm?.enabled === false) { + if (account.config.dm?.enabled === false) { logVerbose(core, runtime, `Blocked Google Chat DM from ${senderId} (dmPolicy=disabled)`); return; } - if (dmPolicy !== "open") { - const allowed = senderAllowedForCommands; - if (!allowed) { - if (dmPolicy === "pairing") { - const { code, created } = await core.channel.pairing.upsertPairingRequest({ - channel: "googlechat", - id: senderId, - meta: { name: senderName || undefined, email: senderEmail }, - }); - if (created) { - logVerbose(core, runtime, `googlechat pairing request sender=${senderId}`); - try { - await sendGoogleChatMessage({ - account, - space: spaceId, - text: core.channel.pairing.buildPairingReply({ - channel: "googlechat", - idLine: `Your Google Chat user id: ${senderId}`, - code, - }), - }); - statusSink?.({ lastOutboundAt: Date.now() }); - } catch (err) { - logVerbose(core, runtime, `pairing reply failed for ${senderId}: ${String(err)}`); - } + if (access.decision !== "allow") { + if (access.decision === "pairing") { + const { code, created } = await core.channel.pairing.upsertPairingRequest({ + channel: "googlechat", + id: senderId, + meta: { name: senderName || undefined, email: senderEmail }, + }); + if (created) { + logVerbose(core, runtime, `googlechat pairing request sender=${senderId}`); + try { + await sendGoogleChatMessage({ + account, + space: spaceId, + text: core.channel.pairing.buildPairingReply({ + channel: "googlechat", + idLine: `Your Google Chat user id: ${senderId}`, + code, + }), + }); + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + logVerbose(core, runtime, `pairing reply failed for ${senderId}: ${String(err)}`); } - } else { - logVerbose( - core, - runtime, - `Blocked unauthorized Google Chat sender ${senderId} (dmPolicy=${dmPolicy})`, - ); } - return; + } else { + logVerbose( + core, + runtime, + `Blocked unauthorized Google Chat sender ${senderId} (dmPolicy=${dmPolicy})`, + ); } + return; } }
extensions/matrix/src/matrix/monitor/handler.ts+63 −41 modified@@ -7,6 +7,7 @@ import { logTypingFailure, readStoreAllowFromForDmPolicy, resolveControlCommandGate, + resolveDmGroupAccessWithLists, type PluginRuntime, type RuntimeEnv, type RuntimeLogger, @@ -214,62 +215,83 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam } const senderName = await getMemberDisplayName(roomId, senderId); - const storeAllowFrom = await readStoreAllowFromForDmPolicy({ - provider: "matrix", + const storeAllowFrom = + isDirectMessage + ? await readStoreAllowFromForDmPolicy({ + provider: "matrix", + dmPolicy, + readStore: (provider) => core.channel.pairing.readAllowFromStore(provider), + }) + : []; + const groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? []; + const normalizedGroupAllowFrom = normalizeMatrixAllowList(groupAllowFrom); + const senderGroupPolicy = + groupPolicy === "disabled" + ? "disabled" + : normalizedGroupAllowFrom.length > 0 + ? "allowlist" + : "open"; + const access = resolveDmGroupAccessWithLists({ + isGroup: isRoom, dmPolicy, - readStore: (provider) => core.channel.pairing.readAllowFromStore(provider), + groupPolicy: senderGroupPolicy, + allowFrom, + groupAllowFrom: normalizedGroupAllowFrom, + storeAllowFrom, + groupAllowFromFallbackToAllowFrom: false, + isSenderAllowed: (allowFrom) => + resolveMatrixAllowListMatches({ + allowList: normalizeMatrixAllowList(allowFrom), + userId: senderId, + }), }); - const effectiveAllowFrom = normalizeMatrixAllowList([...allowFrom, ...storeAllowFrom]); - const groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? []; - const effectiveGroupAllowFrom = normalizeMatrixAllowList(groupAllowFrom); + const effectiveAllowFrom = normalizeMatrixAllowList(access.effectiveAllowFrom); + const effectiveGroupAllowFrom = normalizeMatrixAllowList(access.effectiveGroupAllowFrom); const groupAllowConfigured = effectiveGroupAllowFrom.length > 0; if (isDirectMessage) { - if (!dmEnabled || dmPolicy === "disabled") { + if (!dmEnabled) { return; } - if (dmPolicy !== "open") { + if (access.decision !== "allow") { const allowMatch = resolveMatrixAllowListMatch({ allowList: effectiveAllowFrom, userId: senderId, }); const allowMatchMeta = formatAllowlistMatchMeta(allowMatch); - if (!allowMatch.allowed) { - if (dmPolicy === "pairing") { - const { code, created } = await core.channel.pairing.upsertPairingRequest({ - channel: "matrix", - id: senderId, - meta: { name: senderName }, - }); - if (created) { - logVerboseMessage( - `matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`, - ); - try { - await sendMessageMatrix( - `room:${roomId}`, - [ - "OpenClaw: access not configured.", - "", - `Pairing code: ${code}`, - "", - "Ask the bot owner to approve with:", - "openclaw pairing approve matrix <code>", - ].join("\n"), - { client }, - ); - } catch (err) { - logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`); - } - } - } - if (dmPolicy !== "pairing") { + if (access.decision === "pairing") { + const { code, created } = await core.channel.pairing.upsertPairingRequest({ + channel: "matrix", + id: senderId, + meta: { name: senderName }, + }); + if (created) { logVerboseMessage( - `matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`, + `matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`, ); + try { + await sendMessageMatrix( + `room:${roomId}`, + [ + "OpenClaw: access not configured.", + "", + `Pairing code: ${code}`, + "", + "Ask the bot owner to approve with:", + "openclaw pairing approve matrix <code>", + ].join("\n"), + { client }, + ); + } catch (err) { + logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`); + } } - return; + } else { + logVerboseMessage( + `matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`, + ); } + return; } } @@ -288,7 +310,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam return; } } - if (isRoom && groupPolicy === "allowlist" && roomUsers.length === 0 && groupAllowConfigured) { + if (isRoom && roomUsers.length === 0 && groupAllowConfigured && access.decision !== "allow") { const groupAllowMatch = resolveMatrixAllowListMatch({ allowList: effectiveGroupAllowFrom, userId: senderId,
extensions/mattermost/src/mattermost/monitor.ts+3 −2 modified@@ -390,10 +390,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} const hasControlCommand = core.channel.text.hasControlCommand(rawText, cfg); const isControlCommand = allowTextCommands && hasControlCommand; const useAccessGroups = cfg.commands?.useAccessGroups !== false; + const commandDmAllowFrom = kind === "direct" ? effectiveAllowFrom : normalizedAllowFrom; const senderAllowedForCommands = isMattermostSenderAllowed({ senderId, senderName, - allowFrom: effectiveAllowFrom, + allowFrom: commandDmAllowFrom, allowNameMatching, }); const groupAllowedForCommands = isMattermostSenderAllowed({ @@ -405,7 +406,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} const commandGate = resolveControlCommandGate({ useAccessGroups, authorizers: [ - { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }, + { configured: commandDmAllowFrom.length > 0, allowed: senderAllowedForCommands }, { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands,
extensions/msteams/src/monitor-handler/message-handler.ts+59 −44 modified@@ -11,6 +11,7 @@ import { resolveMentionGating, formatAllowlistMatchMeta, resolveEffectiveAllowFromLists, + resolveDmGroupAccessWithLists, type HistoryEntry, } from "openclaw/plugin-sdk"; import { @@ -146,53 +147,13 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { storeAllowFrom: storedAllowFrom, dmPolicy, }); - const effectiveDmAllowFrom = resolvedAllowFromLists.effectiveAllowFrom; - if (isDirectMessage && msteamsCfg) { - if (dmPolicy === "disabled") { - log.debug?.("dropping dm (dms disabled)"); - return; - } - - if (dmPolicy !== "open") { - const allowNameMatching = isDangerousNameMatchingEnabled(msteamsCfg); - const allowMatch = resolveMSTeamsAllowlistMatch({ - allowFrom: effectiveDmAllowFrom, - senderId, - senderName, - allowNameMatching, - }); - - if (!allowMatch.allowed) { - if (dmPolicy === "pairing") { - const request = await core.channel.pairing.upsertPairingRequest({ - channel: "msteams", - id: senderId, - meta: { name: senderName }, - }); - if (request) { - log.info("msteams pairing request created", { - sender: senderId, - label: senderName, - }); - } - } - log.debug?.("dropping dm (not allowlisted)", { - sender: senderId, - label: senderName, - allowlistMatch: formatAllowlistMatchMeta(allowMatch), - }); - return; - } - } - } const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); const groupPolicy = !isDirectMessage && msteamsCfg ? (msteamsCfg.groupPolicy ?? defaultGroupPolicy ?? "allowlist") : "disabled"; - const effectiveGroupAllowFrom = - !isDirectMessage && msteamsCfg ? resolvedAllowFromLists.effectiveGroupAllowFrom : []; + const effectiveGroupAllowFrom = resolvedAllowFromLists.effectiveGroupAllowFrom; const teamId = activity.channelData?.team?.id; const teamName = activity.channelData?.team?.name; const channelName = activity.channelData?.channel?.name; @@ -203,6 +164,61 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { conversationId, channelName, }); + const senderGroupPolicy = + groupPolicy === "disabled" + ? "disabled" + : effectiveGroupAllowFrom.length > 0 + ? "allowlist" + : "open"; + const access = resolveDmGroupAccessWithLists({ + isGroup: !isDirectMessage, + dmPolicy, + groupPolicy: senderGroupPolicy, + allowFrom: configuredDmAllowFrom, + groupAllowFrom, + storeAllowFrom: storedAllowFrom, + groupAllowFromFallbackToAllowFrom: false, + isSenderAllowed: (allowFrom) => + resolveMSTeamsAllowlistMatch({ + allowFrom, + senderId, + senderName, + allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg), + }).allowed, + }); + const effectiveDmAllowFrom = access.effectiveAllowFrom; + + if (isDirectMessage && msteamsCfg && access.decision !== "allow") { + if (access.reason === "dmPolicy=disabled") { + log.debug?.("dropping dm (dms disabled)"); + return; + } + const allowMatch = resolveMSTeamsAllowlistMatch({ + allowFrom: effectiveDmAllowFrom, + senderId, + senderName, + allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg), + }); + if (access.decision === "pairing") { + const request = await core.channel.pairing.upsertPairingRequest({ + channel: "msteams", + id: senderId, + meta: { name: senderName }, + }); + if (request) { + log.info("msteams pairing request created", { + sender: senderId, + label: senderName, + }); + } + } + log.debug?.("dropping dm (not allowlisted)", { + sender: senderId, + label: senderName, + allowlistMatch: formatAllowlistMatchMeta(allowMatch), + }); + return; + } if (!isDirectMessage && msteamsCfg) { if (groupPolicy === "disabled") { @@ -229,13 +245,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { }); return; } - if (effectiveGroupAllowFrom.length > 0) { - const allowNameMatching = isDangerousNameMatchingEnabled(msteamsCfg); + if (effectiveGroupAllowFrom.length > 0 && access.decision !== "allow") { const allowMatch = resolveMSTeamsAllowlistMatch({ allowFrom: effectiveGroupAllowFrom, senderId, senderName, - allowNameMatching, + allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg), }); if (!allowMatch.allowed) { log.debug?.("dropping group message (not in groupAllowFrom)", {
extensions/nextcloud-talk/src/inbound.ts+48 −57 modified@@ -5,7 +5,7 @@ import { formatTextWithAttachmentLinks, logInboundDrop, readStoreAllowFromForDmPolicy, - resolveControlCommandGate, + resolveDmGroupAccessWithCommandGate, resolveOutboundMediaUrls, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, @@ -120,37 +120,40 @@ export async function handleNextcloudTalkInbound(params: { } const roomAllowFrom = normalizeNextcloudTalkAllowlist(roomConfig?.allowFrom); - const baseGroupAllowFrom = - configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom; - - const effectiveAllowFrom = [...configAllowFrom, ...storeAllowList].filter(Boolean); - const effectiveGroupAllowFrom = [...baseGroupAllowFrom].filter(Boolean); const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ cfg: config as OpenClawConfig, surface: CHANNEL_ID, }); const useAccessGroups = (config.commands as Record<string, unknown> | undefined)?.useAccessGroups !== false; - const senderAllowedForCommands = resolveNextcloudTalkAllowlistMatch({ - allowFrom: isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom, - senderId, - }).allowed; const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig); - const commandGate = resolveControlCommandGate({ - useAccessGroups, - authorizers: [ - { - configured: (isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom).length > 0, - allowed: senderAllowedForCommands, - }, - ], - allowTextCommands, - hasControlCommand, + const access = resolveDmGroupAccessWithCommandGate({ + isGroup, + dmPolicy, + groupPolicy, + allowFrom: configAllowFrom, + groupAllowFrom: configGroupAllowFrom, + storeAllowFrom: storeAllowList, + isSenderAllowed: (allowFrom) => + resolveNextcloudTalkAllowlistMatch({ + allowFrom, + senderId, + }).allowed, + command: { + useAccessGroups, + allowTextCommands, + hasControlCommand, + }, }); - const commandAuthorized = commandGate.commandAuthorized; + const commandAuthorized = access.commandAuthorized; + const effectiveGroupAllowFrom = access.effectiveGroupAllowFrom; if (isGroup) { + if (access.decision !== "allow") { + runtime.log?.(`nextcloud-talk: drop group sender ${senderId} (reason=${access.reason})`); + return; + } const groupAllow = resolveNextcloudTalkGroupAllow({ groupPolicy, outerAllowFrom: effectiveGroupAllowFrom, @@ -162,48 +165,36 @@ export async function handleNextcloudTalkInbound(params: { return; } } else { - if (dmPolicy === "disabled") { - runtime.log?.(`nextcloud-talk: drop DM sender=${senderId} (dmPolicy=disabled)`); - return; - } - if (dmPolicy !== "open") { - const dmAllowed = resolveNextcloudTalkAllowlistMatch({ - allowFrom: effectiveAllowFrom, - senderId, - }).allowed; - if (!dmAllowed) { - if (dmPolicy === "pairing") { - const { code, created } = await core.channel.pairing.upsertPairingRequest({ - channel: CHANNEL_ID, - id: senderId, - meta: { name: senderName || undefined }, - }); - if (created) { - try { - await sendMessageNextcloudTalk( - roomToken, - core.channel.pairing.buildPairingReply({ - channel: CHANNEL_ID, - idLine: `Your Nextcloud user id: ${senderId}`, - code, - }), - { accountId: account.accountId }, - ); - statusSink?.({ lastOutboundAt: Date.now() }); - } catch (err) { - runtime.error?.( - `nextcloud-talk: pairing reply failed for ${senderId}: ${String(err)}`, - ); - } + if (access.decision !== "allow") { + if (access.decision === "pairing") { + const { code, created } = await core.channel.pairing.upsertPairingRequest({ + channel: CHANNEL_ID, + id: senderId, + meta: { name: senderName || undefined }, + }); + if (created) { + try { + await sendMessageNextcloudTalk( + roomToken, + core.channel.pairing.buildPairingReply({ + channel: CHANNEL_ID, + idLine: `Your Nextcloud user id: ${senderId}`, + code, + }), + { accountId: account.accountId }, + ); + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + runtime.error?.(`nextcloud-talk: pairing reply failed for ${senderId}: ${String(err)}`); } } - runtime.log?.(`nextcloud-talk: drop DM sender ${senderId} (dmPolicy=${dmPolicy})`); - return; } + runtime.log?.(`nextcloud-talk: drop DM sender ${senderId} (reason=${access.reason})`); + return; } } - if (isGroup && commandGate.shouldBlock) { + if (access.shouldBlockControlCommand) { logInboundDrop({ log: (message) => runtime.log?.(message), channel: CHANNEL_ID,
extensions/zalo/src/monitor.ts+1 −0 modified@@ -355,6 +355,7 @@ async function processMessageWithPipeline(params: { isGroup, dmPolicy, configuredAllowFrom: configAllowFrom, + configuredGroupAllowFrom: groupAllowFrom, senderId, isSenderAllowed: isZaloSenderAllowed, readAllowFromStore: () => core.channel.pairing.readAllowFromStore("zalo"),
src/imessage/monitor/inbound-processing.ts+4 −3 modified@@ -256,10 +256,11 @@ export function resolveIMessageInboundDecision(params: { const canDetectMention = mentionRegexes.length > 0; const useAccessGroups = params.cfg.commands?.useAccessGroups !== false; + const commandDmAllowFrom = isGroup ? params.allowFrom : effectiveDmAllowFrom; const ownerAllowedForCommands = - effectiveDmAllowFrom.length > 0 + commandDmAllowFrom.length > 0 ? isAllowedIMessageSender({ - allowFrom: effectiveDmAllowFrom, + allowFrom: commandDmAllowFrom, sender, chatId, chatGuid, @@ -280,7 +281,7 @@ export function resolveIMessageInboundDecision(params: { const commandGate = resolveControlCommandGate({ useAccessGroups, authorizers: [ - { configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands }, + { configured: commandDmAllowFrom.length > 0, allowed: ownerAllowedForCommands }, { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, ], allowTextCommands: true,
src/plugin-sdk/command-auth.test.ts+51 −0 added@@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveSenderCommandAuthorization } from "./command-auth.js"; + +const baseCfg = { + commands: { useAccessGroups: true }, +} as unknown as OpenClawConfig; + +describe("plugin-sdk/command-auth", () => { + it("authorizes group commands from explicit group allowlist", async () => { + const result = await resolveSenderCommandAuthorization({ + cfg: baseCfg, + rawBody: "/status", + isGroup: true, + dmPolicy: "pairing", + configuredAllowFrom: ["dm-owner"], + configuredGroupAllowFrom: ["group-owner"], + senderId: "group-owner", + isSenderAllowed: (senderId, allowFrom) => allowFrom.includes(senderId), + readAllowFromStore: async () => ["paired-user"], + shouldComputeCommandAuthorized: () => true, + resolveCommandAuthorizedFromAuthorizers: ({ useAccessGroups, authorizers }) => + useAccessGroups && authorizers.some((entry) => entry.configured && entry.allowed), + }); + expect(result.commandAuthorized).toBe(true); + expect(result.senderAllowedForCommands).toBe(true); + expect(result.effectiveAllowFrom).toEqual(["dm-owner"]); + expect(result.effectiveGroupAllowFrom).toEqual(["group-owner"]); + }); + + it("keeps pairing-store identities DM-only for group command auth", async () => { + const result = await resolveSenderCommandAuthorization({ + cfg: baseCfg, + rawBody: "/status", + isGroup: true, + dmPolicy: "pairing", + configuredAllowFrom: ["dm-owner"], + configuredGroupAllowFrom: ["group-owner"], + senderId: "paired-user", + isSenderAllowed: (senderId, allowFrom) => allowFrom.includes(senderId), + readAllowFromStore: async () => ["paired-user"], + shouldComputeCommandAuthorized: () => true, + resolveCommandAuthorizedFromAuthorizers: ({ useAccessGroups, authorizers }) => + useAccessGroups && authorizers.some((entry) => entry.configured && entry.allowed), + }); + expect(result.commandAuthorized).toBe(false); + expect(result.senderAllowedForCommands).toBe(false); + expect(result.effectiveAllowFrom).toEqual(["dm-owner"]); + expect(result.effectiveGroupAllowFrom).toEqual(["group-owner"]); + }); +});
src/plugin-sdk/command-auth.ts+23 −3 modified@@ -1,11 +1,13 @@ import type { OpenClawConfig } from "../config/config.js"; +import { resolveDmGroupAccessWithLists } from "../security/dm-policy-shared.js"; export type ResolveSenderCommandAuthorizationParams = { cfg: OpenClawConfig; rawBody: string; isGroup: boolean; dmPolicy: string; configuredAllowFrom: string[]; + configuredGroupAllowFrom?: string[]; senderId: string; isSenderAllowed: (senderId: string, allowFrom: string[]) => boolean; readAllowFromStore: () => Promise<string[]>; @@ -21,6 +23,7 @@ export async function resolveSenderCommandAuthorization( ): Promise<{ shouldComputeAuth: boolean; effectiveAllowFrom: string[]; + effectiveGroupAllowFrom: string[]; senderAllowedForCommands: boolean; commandAuthorized: boolean | undefined; }> { @@ -31,21 +34,38 @@ export async function resolveSenderCommandAuthorization( (params.dmPolicy !== "open" || shouldComputeAuth) ? await params.readAllowFromStore().catch(() => []) : []; - const effectiveAllowFrom = [...params.configuredAllowFrom, ...storeAllowFrom]; + const access = resolveDmGroupAccessWithLists({ + isGroup: params.isGroup, + dmPolicy: params.dmPolicy, + groupPolicy: "allowlist", + allowFrom: params.configuredAllowFrom, + groupAllowFrom: params.configuredGroupAllowFrom ?? [], + storeAllowFrom, + isSenderAllowed: (allowFrom) => params.isSenderAllowed(params.senderId, allowFrom), + }); + const effectiveAllowFrom = access.effectiveAllowFrom; + const effectiveGroupAllowFrom = access.effectiveGroupAllowFrom; const useAccessGroups = params.cfg.commands?.useAccessGroups !== false; - const senderAllowedForCommands = params.isSenderAllowed(params.senderId, effectiveAllowFrom); + const senderAllowedForCommands = params.isSenderAllowed( + params.senderId, + params.isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom, + ); + const ownerAllowedForCommands = params.isSenderAllowed(params.senderId, effectiveAllowFrom); + const groupAllowedForCommands = params.isSenderAllowed(params.senderId, effectiveGroupAllowFrom); const commandAuthorized = shouldComputeAuth ? params.resolveCommandAuthorizedFromAuthorizers({ useAccessGroups, authorizers: [ - { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }, + { configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands }, + { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, ], }) : undefined; return { shouldComputeAuth, effectiveAllowFrom, + effectiveGroupAllowFrom, senderAllowedForCommands, commandAuthorized, };
src/plugin-sdk/index.ts+1 −0 modified@@ -413,6 +413,7 @@ export { readStoreAllowFromForDmPolicy, resolveDmAllowState, resolveDmGroupAccessDecision, + resolveDmGroupAccessWithCommandGate, resolveDmGroupAccessWithLists, resolveEffectiveAllowFromLists, } from "../security/dm-policy-shared.js";
src/security/dm-policy-shared.test.ts+61 −0 modified@@ -3,6 +3,7 @@ import { DM_GROUP_ACCESS_REASON, readStoreAllowFromForDmPolicy, resolveDmAllowState, + resolveDmGroupAccessWithCommandGate, resolveDmGroupAccessDecision, resolveDmGroupAccessWithLists, resolveEffectiveAllowFromLists, @@ -134,6 +135,66 @@ describe("security/dm-policy-shared", () => { expect(resolved.effectiveGroupAllowFrom).toEqual(["group:room"]); }); + it("resolves command gate with dm/group parity for groups", () => { + const resolved = resolveDmGroupAccessWithCommandGate({ + isGroup: true, + dmPolicy: "pairing", + groupPolicy: "allowlist", + allowFrom: ["owner"], + groupAllowFrom: ["group-owner"], + storeAllowFrom: ["paired-user"], + isSenderAllowed: (allowFrom) => allowFrom.includes("paired-user"), + command: { + useAccessGroups: true, + allowTextCommands: true, + hasControlCommand: true, + }, + }); + expect(resolved.decision).toBe("block"); + expect(resolved.reason).toBe("groupPolicy=allowlist (not allowlisted)"); + expect(resolved.commandAuthorized).toBe(false); + expect(resolved.shouldBlockControlCommand).toBe(true); + }); + + it("keeps configured dm allowlist usable for group command auth", () => { + const resolved = resolveDmGroupAccessWithCommandGate({ + isGroup: true, + dmPolicy: "pairing", + groupPolicy: "open", + allowFrom: ["owner"], + groupAllowFrom: [], + storeAllowFrom: ["paired-user"], + isSenderAllowed: (allowFrom) => allowFrom.includes("owner"), + command: { + useAccessGroups: true, + allowTextCommands: true, + hasControlCommand: true, + }, + }); + expect(resolved.commandAuthorized).toBe(true); + expect(resolved.shouldBlockControlCommand).toBe(false); + }); + + it("treats dm command authorization as dm access result", () => { + const resolved = resolveDmGroupAccessWithCommandGate({ + isGroup: false, + dmPolicy: "pairing", + groupPolicy: "allowlist", + allowFrom: ["owner"], + groupAllowFrom: ["group-owner"], + storeAllowFrom: ["paired-user"], + isSenderAllowed: (allowFrom) => allowFrom.includes("paired-user"), + command: { + useAccessGroups: true, + allowTextCommands: true, + hasControlCommand: true, + }, + }); + expect(resolved.decision).toBe("allow"); + expect(resolved.commandAuthorized).toBe(true); + expect(resolved.shouldBlockControlCommand).toBe(false); + }); + it("keeps allowlist mode strict in shared resolver (no pairing-store fallback)", () => { const resolved = resolveDmGroupAccessWithLists({ isGroup: false,
src/security/dm-policy-shared.ts+74 −0 modified@@ -1,4 +1,5 @@ import { mergeDmAllowFromSources, resolveGroupAllowFromSources } from "../channels/allow-from.js"; +import { resolveControlCommandGate } from "../channels/command-gating.js"; import type { ChannelId } from "../channels/plugins/types.js"; import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { normalizeStringEntries } from "../shared/string-normalization.js"; @@ -182,6 +183,79 @@ export function resolveDmGroupAccessWithLists(params: { }; } +export function resolveDmGroupAccessWithCommandGate(params: { + isGroup: boolean; + dmPolicy?: string | null; + groupPolicy?: string | null; + allowFrom?: Array<string | number> | null; + groupAllowFrom?: Array<string | number> | null; + storeAllowFrom?: Array<string | number> | null; + groupAllowFromFallbackToAllowFrom?: boolean | null; + isSenderAllowed: (allowFrom: string[]) => boolean; + command?: { + useAccessGroups: boolean; + allowTextCommands: boolean; + hasControlCommand: boolean; + }; +}): { + decision: DmGroupAccessDecision; + reason: string; + effectiveAllowFrom: string[]; + effectiveGroupAllowFrom: string[]; + commandAuthorized: boolean; + shouldBlockControlCommand: boolean; +} { + const access = resolveDmGroupAccessWithLists({ + isGroup: params.isGroup, + dmPolicy: params.dmPolicy, + groupPolicy: params.groupPolicy, + allowFrom: params.allowFrom, + groupAllowFrom: params.groupAllowFrom, + storeAllowFrom: params.storeAllowFrom, + groupAllowFromFallbackToAllowFrom: params.groupAllowFromFallbackToAllowFrom, + isSenderAllowed: params.isSenderAllowed, + }); + + const configuredAllowFrom = normalizeStringEntries(params.allowFrom ?? []); + const configuredGroupAllowFrom = normalizeStringEntries( + resolveGroupAllowFromSources({ + allowFrom: configuredAllowFrom, + groupAllowFrom: normalizeStringEntries(params.groupAllowFrom ?? []), + fallbackToAllowFrom: params.groupAllowFromFallbackToAllowFrom ?? undefined, + }), + ); + // Group command authorization must not inherit DM pairing-store approvals. + const commandDmAllowFrom = params.isGroup ? configuredAllowFrom : access.effectiveAllowFrom; + const commandGroupAllowFrom = params.isGroup + ? configuredGroupAllowFrom + : access.effectiveGroupAllowFrom; + const ownerAllowedForCommands = params.isSenderAllowed(commandDmAllowFrom); + const groupAllowedForCommands = params.isSenderAllowed(commandGroupAllowFrom); + const commandGate = params.command + ? resolveControlCommandGate({ + useAccessGroups: params.command.useAccessGroups, + authorizers: [ + { + configured: commandDmAllowFrom.length > 0, + allowed: ownerAllowedForCommands, + }, + { + configured: commandGroupAllowFrom.length > 0, + allowed: groupAllowedForCommands, + }, + ], + allowTextCommands: params.command.allowTextCommands, + hasControlCommand: params.command.hasControlCommand, + }) + : { commandAuthorized: false, shouldBlock: false }; + + return { + ...access, + commandAuthorized: params.isGroup ? commandGate.commandAuthorized : access.decision === "allow", + shouldBlockControlCommand: params.isGroup && commandGate.shouldBlock, + }; +} + export async function resolveDmAllowState(params: { provider: ChannelId; allowFrom?: Array<string | number> | null;
src/signal/monitor/event-handler.ts+3 −2 modified@@ -560,13 +560,14 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { } const useAccessGroups = deps.cfg.commands?.useAccessGroups !== false; - const ownerAllowedForCommands = isSignalSenderAllowed(sender, effectiveDmAllow); + const commandDmAllow = isGroup ? deps.allowFrom : effectiveDmAllow; + const ownerAllowedForCommands = isSignalSenderAllowed(sender, commandDmAllow); const groupAllowedForCommands = isSignalSenderAllowed(sender, effectiveGroupAllow); const hasControlCommandInMessage = hasControlCommand(messageText, deps.cfg); const commandGate = resolveControlCommandGate({ useAccessGroups, authorizers: [ - { configured: effectiveDmAllow.length > 0, allowed: ownerAllowedForCommands }, + { configured: commandDmAllow.length > 0, allowed: ownerAllowedForCommands }, { configured: effectiveGroupAllow.length > 0, allowed: groupAllowedForCommands }, ], allowTextCommands: true,
src/slack/monitor/auth.ts+17 −10 modified@@ -9,12 +9,19 @@ import { import { resolveSlackChannelConfig } from "./channel-config.js"; import { normalizeSlackChannelType, type SlackMonitorContext } from "./context.js"; -export async function resolveSlackEffectiveAllowFrom(ctx: SlackMonitorContext) { - const storeAllowFrom = await readStoreAllowFromForDmPolicy({ - provider: "slack", - dmPolicy: ctx.dmPolicy, - readStore: (provider) => readChannelAllowFromStore(provider), - }); +export async function resolveSlackEffectiveAllowFrom( + ctx: SlackMonitorContext, + options?: { includePairingStore?: boolean }, +) { + const includePairingStore = options?.includePairingStore === true; + const storeAllowFrom = + includePairingStore + ? await readStoreAllowFromForDmPolicy({ + provider: "slack", + dmPolicy: ctx.dmPolicy, + readStore: (provider) => readChannelAllowFromStore(provider), + }) + : []; const allowFrom = normalizeAllowList([...ctx.allowFrom, ...storeAllowFrom]); const allowFromLower = normalizeAllowListLower(allowFrom); return { allowFrom, allowFromLower }; @@ -99,15 +106,15 @@ export async function authorizeSlackSystemEventSender(params: { .catch(() => ({})); const senderName = senderInfo.name; - const resolveAllowFromLower = async () => - (await resolveSlackEffectiveAllowFrom(params.ctx)).allowFromLower; + const resolveAllowFromLower = async (includePairingStore = false) => + (await resolveSlackEffectiveAllowFrom(params.ctx, { includePairingStore })).allowFromLower; if (channelType === "im") { if (!params.ctx.dmEnabled || params.ctx.dmPolicy === "disabled") { return { allowed: false, reason: "dm-disabled", channelType, channelName }; } if (params.ctx.dmPolicy !== "open") { - const allowFromLower = await resolveAllowFromLower(); + const allowFromLower = await resolveAllowFromLower(true); const senderAllowListed = isSlackSenderAllowListed({ allowListLower: allowFromLower, senderId, @@ -126,7 +133,7 @@ export async function authorizeSlackSystemEventSender(params: { } else if (!channelId) { // No channel context. Apply allowFrom if configured so we fail closed // for privileged interactive events when owner allowlist is present. - const allowFromLower = await resolveAllowFromLower(); + const allowFromLower = await resolveAllowFromLower(false); if (allowFromLower.length > 0) { const senderAllowListed = isSlackSenderAllowListed({ allowListLower: allowFromLower,
src/slack/monitor/message-handler/prepare.ts+3 −1 modified@@ -127,7 +127,9 @@ export async function prepareSlackMessage(params: { return null; } - const { allowFromLower } = await resolveSlackEffectiveAllowFrom(ctx); + const { allowFromLower } = await resolveSlackEffectiveAllowFrom(ctx, { + includePairingStore: isDirectMessage, + }); if (isDirectMessage) { const directUserId = message.user;
src/slack/monitor/slash.ts+8 −5 modified@@ -336,11 +336,14 @@ export async function registerSlackMonitorSlashCommands(params: { return; } - const storeAllowFrom = await readStoreAllowFromForDmPolicy({ - provider: "slack", - dmPolicy: ctx.dmPolicy, - readStore: (provider) => readChannelAllowFromStore(provider), - }); + const storeAllowFrom = + isDirectMessage + ? await readStoreAllowFromForDmPolicy({ + provider: "slack", + dmPolicy: ctx.dmPolicy, + readStore: (provider) => readChannelAllowFromStore(provider), + }) + : []; const effectiveAllowFrom = normalizeAllowList([...ctx.allowFrom, ...storeAllowFrom]); const effectiveAllowFromLower = normalizeAllowListLower(effectiveAllowFrom);
src/telegram/bot-native-commands.ts+1 −1 modified@@ -253,7 +253,7 @@ async function resolveTelegramCommandAuth(params: { const dmAllow = normalizeDmAllowFromWithStore({ allowFrom: allowFrom, - storeAllowFrom, + storeAllowFrom: isGroup ? [] : storeAllowFrom, dmPolicy: telegramCfg.dmPolicy ?? "pairing", }); const senderAllowed = isSenderAllowed({
src/web/auto-reply/monitor/process-message.ts+40 −35 modified@@ -27,7 +27,10 @@ import type { getChildLogger } from "../../../logging.js"; import { getAgentScopedMediaLocalRoots } from "../../../media/local-roots.js"; import { readChannelAllowFromStore } from "../../../pairing/pairing-store.js"; import type { resolveAgentRoute } from "../../../routing/resolve-route.js"; -import { readStoreAllowFromForDmPolicy } from "../../../security/dm-policy-shared.js"; +import { + readStoreAllowFromForDmPolicy, + resolveDmGroupAccessWithCommandGate, +} from "../../../security/dm-policy-shared.js"; import { jidToE164, normalizeE164 } from "../../../utils.js"; import { resolveWhatsAppAccount } from "../../accounts.js"; import { newConnectionId } from "../../reconnect.js"; @@ -49,15 +52,6 @@ export type GroupHistoryEntry = { senderJid?: string; }; -function normalizeAllowFromE164(values: Array<string | number> | undefined): string[] { - const list = Array.isArray(values) ? values : []; - return list - .map((entry) => String(entry).trim()) - .filter((entry) => entry && entry !== "*") - .map((entry) => normalizeE164(entry)) - .filter((entry): entry is string => Boolean(entry)); -} - async function resolveWhatsAppCommandAuthorized(params: { cfg: ReturnType<typeof loadConfig>; msg: WebInboundMsg; @@ -77,38 +71,49 @@ async function resolveWhatsAppCommandAuthorized(params: { const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.msg.accountId }); const dmPolicy = account.dmPolicy ?? "pairing"; + const groupPolicy = account.groupPolicy ?? "allowlist"; const configuredAllowFrom = account.allowFrom ?? []; const configuredGroupAllowFrom = account.groupAllowFrom ?? (configuredAllowFrom.length > 0 ? configuredAllowFrom : undefined); - if (isGroup) { - if (!configuredGroupAllowFrom || configuredGroupAllowFrom.length === 0) { - return false; - } - if (configuredGroupAllowFrom.some((v) => String(v).trim() === "*")) { - return true; - } - return normalizeAllowFromE164(configuredGroupAllowFrom).includes(senderE164); - } - - const storeAllowFrom = await readStoreAllowFromForDmPolicy({ - provider: "whatsapp", - dmPolicy, - readStore: (provider) => readChannelAllowFromStore(provider, process.env, params.msg.accountId), - }); - const combinedAllowFrom = Array.from( - new Set([...(configuredAllowFrom ?? []), ...storeAllowFrom]), - ); - const allowFrom = - combinedAllowFrom.length > 0 - ? combinedAllowFrom + const storeAllowFrom = + isGroup + ? [] + : await readStoreAllowFromForDmPolicy({ + provider: "whatsapp", + dmPolicy, + readStore: (provider) => + readChannelAllowFromStore(provider, process.env, params.msg.accountId), + }); + const dmAllowFrom = + configuredAllowFrom.length > 0 + ? configuredAllowFrom : params.msg.selfE164 ? [params.msg.selfE164] : []; - if (allowFrom.some((v) => String(v).trim() === "*")) { - return true; - } - return normalizeAllowFromE164(allowFrom).includes(senderE164); + const access = resolveDmGroupAccessWithCommandGate({ + isGroup, + dmPolicy, + groupPolicy, + allowFrom: dmAllowFrom, + groupAllowFrom: configuredGroupAllowFrom, + storeAllowFrom, + isSenderAllowed: (allowEntries) => { + if (allowEntries.includes("*")) { + return true; + } + const normalizedEntries = allowEntries + .map((entry) => normalizeE164(String(entry))) + .filter((entry): entry is string => Boolean(entry)); + return normalizedEntries.includes(senderE164); + }, + command: { + useAccessGroups, + allowTextCommands: true, + hasControlCommand: true, + }, + }); + return access.commandAuthorized; } export async function processMessage(params: {
src/web/inbound/access-control.ts+84 −85 modified@@ -10,7 +10,10 @@ import { readChannelAllowFromStore, upsertChannelPairingRequest, } from "../../pairing/pairing-store.js"; -import { readStoreAllowFromForDmPolicy } from "../../security/dm-policy-shared.js"; +import { + readStoreAllowFromForDmPolicy, + resolveDmGroupAccessWithLists, +} from "../../security/dm-policy-shared.js"; import { isSelfChatMode, normalizeE164 } from "../../utils.js"; import { resolveWhatsAppAccount } from "../accounts.js"; @@ -60,22 +63,18 @@ export async function checkInboundAccessControl(params: { accountId: params.accountId, }); const dmPolicy = account.dmPolicy ?? "pairing"; - const configuredAllowFrom = account.allowFrom; + const configuredAllowFrom = account.allowFrom ?? []; const storeAllowFrom = await readStoreAllowFromForDmPolicy({ provider: "whatsapp", dmPolicy, readStore: (provider) => readChannelAllowFromStore(provider, process.env, account.accountId), }); // Without user config, default to self-only DM access so the owner can talk to themselves. - const combinedAllowFrom = Array.from( - new Set([...(configuredAllowFrom ?? []), ...storeAllowFrom]), - ); const defaultAllowFrom = - combinedAllowFrom.length === 0 && params.selfE164 ? [params.selfE164] : undefined; - const allowFrom = combinedAllowFrom.length > 0 ? combinedAllowFrom : defaultAllowFrom; + configuredAllowFrom.length === 0 && params.selfE164 ? [params.selfE164] : []; + const dmAllowFrom = configuredAllowFrom.length > 0 ? configuredAllowFrom : defaultAllowFrom; const groupAllowFrom = - account.groupAllowFrom ?? - (configuredAllowFrom && configuredAllowFrom.length > 0 ? configuredAllowFrom : undefined); + account.groupAllowFrom ?? (configuredAllowFrom.length > 0 ? configuredAllowFrom : undefined); const isSamePhone = params.from === params.selfE164; const isSelfChat = account.selfChatMode ?? isSelfChatMode(params.selfE164, configuredAllowFrom); const pairingGraceMs = @@ -87,18 +86,6 @@ export async function checkInboundAccessControl(params: { typeof params.messageTimestampMs === "number" && params.messageTimestampMs < params.connectedAtMs - pairingGraceMs; - // Pre-compute normalized allowlists for filtering. - const dmHasWildcard = allowFrom?.includes("*") ?? false; - const normalizedAllowFrom = - allowFrom && allowFrom.length > 0 - ? allowFrom.filter((entry) => entry !== "*").map(normalizeE164) - : []; - const groupHasWildcard = groupAllowFrom?.includes("*") ?? false; - const normalizedGroupAllowFrom = - groupAllowFrom && groupAllowFrom.length > 0 - ? groupAllowFrom.filter((entry) => entry !== "*").map(normalizeE164) - : []; - // Group policy filtering: // - "open": groups bypass allowFrom, only mention-gating applies // - "disabled": block all group messages entirely @@ -115,105 +102,117 @@ export async function checkInboundAccessControl(params: { accountId: account.accountId, log: (message) => logVerbose(message), }); - if (params.group && groupPolicy === "disabled") { - logVerbose("Blocked group message (groupPolicy: disabled)"); + const normalizedDmSender = normalizeE164(params.from); + const normalizedGroupSender = + typeof params.senderE164 === "string" ? normalizeE164(params.senderE164) : null; + const access = resolveDmGroupAccessWithLists({ + isGroup: params.group, + dmPolicy, + groupPolicy, + // Groups intentionally fall back to configured allowFrom only (not DM self-chat fallback). + allowFrom: params.group ? configuredAllowFrom : dmAllowFrom, + groupAllowFrom, + storeAllowFrom, + isSenderAllowed: (allowEntries) => { + const hasWildcard = allowEntries.includes("*"); + if (hasWildcard) { + return true; + } + const normalizedEntrySet = new Set( + allowEntries + .map((entry) => normalizeE164(String(entry))) + .filter((entry): entry is string => Boolean(entry)), + ); + if (!params.group && isSamePhone) { + return true; + } + return params.group + ? Boolean(normalizedGroupSender && normalizedEntrySet.has(normalizedGroupSender)) + : normalizedEntrySet.has(normalizedDmSender); + }, + }); + if (params.group && access.decision !== "allow") { + if (access.reason === "groupPolicy=disabled") { + logVerbose("Blocked group message (groupPolicy: disabled)"); + } else if (access.reason === "groupPolicy=allowlist (empty allowlist)") { + logVerbose("Blocked group message (groupPolicy: allowlist, no groupAllowFrom)"); + } else { + logVerbose( + `Blocked group message from ${params.senderE164 ?? "unknown sender"} (groupPolicy: allowlist)`, + ); + } return { allowed: false, shouldMarkRead: false, isSelfChat, resolvedAccountId: account.accountId, }; } - if (params.group && groupPolicy === "allowlist") { - if (!groupAllowFrom || groupAllowFrom.length === 0) { - logVerbose("Blocked group message (groupPolicy: allowlist, no groupAllowFrom)"); + + // DM access control (secure defaults): "pairing" (default) / "allowlist" / "open" / "disabled". + if (!params.group) { + if (params.isFromMe && !isSamePhone) { + logVerbose("Skipping outbound DM (fromMe); no pairing reply needed."); return { allowed: false, shouldMarkRead: false, isSelfChat, resolvedAccountId: account.accountId, }; } - const senderAllowed = - groupHasWildcard || - (params.senderE164 != null && normalizedGroupAllowFrom.includes(params.senderE164)); - if (!senderAllowed) { - logVerbose( - `Blocked group message from ${params.senderE164 ?? "unknown sender"} (groupPolicy: allowlist)`, - ); + if (access.decision === "block" && access.reason === "dmPolicy=disabled") { + logVerbose("Blocked dm (dmPolicy: disabled)"); return { allowed: false, shouldMarkRead: false, isSelfChat, resolvedAccountId: account.accountId, }; } - } - - // DM access control (secure defaults): "pairing" (default) / "allowlist" / "open" / "disabled". - if (!params.group) { - if (params.isFromMe && !isSamePhone) { - logVerbose("Skipping outbound DM (fromMe); no pairing reply needed."); + if (access.decision === "pairing" && !isSamePhone) { + const candidate = params.from; + if (suppressPairingReply) { + logVerbose(`Skipping pairing reply for historical DM from ${candidate}.`); + } else { + const { code, created } = await upsertChannelPairingRequest({ + channel: "whatsapp", + id: candidate, + accountId: account.accountId, + meta: { name: (params.pushName ?? "").trim() || undefined }, + }); + if (created) { + logVerbose( + `whatsapp pairing request sender=${candidate} name=${params.pushName ?? "unknown"}`, + ); + try { + await params.sock.sendMessage(params.remoteJid, { + text: buildPairingReply({ + channel: "whatsapp", + idLine: `Your WhatsApp phone number: ${candidate}`, + code, + }), + }); + } catch (err) { + logVerbose(`whatsapp pairing reply failed for ${candidate}: ${String(err)}`); + } + } + } return { allowed: false, shouldMarkRead: false, isSelfChat, resolvedAccountId: account.accountId, }; } - if (dmPolicy === "disabled") { - logVerbose("Blocked dm (dmPolicy: disabled)"); + if (access.decision !== "allow") { + logVerbose(`Blocked unauthorized sender ${params.from} (dmPolicy=${dmPolicy})`); return { allowed: false, shouldMarkRead: false, isSelfChat, resolvedAccountId: account.accountId, }; } - if (dmPolicy !== "open" && !isSamePhone) { - const candidate = params.from; - const allowed = - dmHasWildcard || - (normalizedAllowFrom.length > 0 && normalizedAllowFrom.includes(candidate)); - if (!allowed) { - if (dmPolicy === "pairing") { - if (suppressPairingReply) { - logVerbose(`Skipping pairing reply for historical DM from ${candidate}.`); - } else { - const { code, created } = await upsertChannelPairingRequest({ - channel: "whatsapp", - id: candidate, - accountId: account.accountId, - meta: { name: (params.pushName ?? "").trim() || undefined }, - }); - if (created) { - logVerbose( - `whatsapp pairing request sender=${candidate} name=${params.pushName ?? "unknown"}`, - ); - try { - await params.sock.sendMessage(params.remoteJid, { - text: buildPairingReply({ - channel: "whatsapp", - idLine: `Your WhatsApp phone number: ${candidate}`, - code, - }), - }); - } catch (err) { - logVerbose(`whatsapp pairing reply failed for ${candidate}: ${String(err)}`); - } - } - } - } else { - logVerbose(`Blocked unauthorized sender ${candidate} (dmPolicy=${dmPolicy})`); - } - return { - allowed: false, - shouldMarkRead: false, - isSelfChat, - resolvedAccountId: account.accountId, - }; - } - } } return {
8bdda7a651c2fix(security): keep DM pairing allowlists out of group auth
15 files changed · +194 −54
CHANGELOG.md+1 −0 modified@@ -95,6 +95,7 @@ Docs: https://docs.openclaw.ai - Security/Slack member + message subtype events: gate `member_*` plus `message_changed`/`message_deleted`/`thread_broadcast` system-event enqueue through shared sender authorization so DM `dmPolicy`/`allowFrom` and channel `users` allowlists are enforced consistently for non-message ingress; message subtype system events now fail closed when sender identity is missing, with regression coverage. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. - Security/Telegram reactions: enforce `dmPolicy`/`allowFrom` and group allowlist authorization on `message_reaction` events before enqueueing reaction system events, preventing unauthorized reaction-triggered input in DMs and groups; ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. - Security/Telegram group allowlist: fail closed for group sender authorization by removing DM pairing-store fallback from group allowlist evaluation; group sender access now requires explicit `groupAllowFrom` or per-group/per-topic `allowFrom`. (#25988) Thanks @bmendonca3. +- Security/DM-group allowlist boundaries: keep DM pairing-store approvals DM-only by removing pairing-store inheritance from group sender authorization in LINE and Mattermost message preflight, and by centralizing shared DM/group allowlist composition so group checks never include pairing-store entries. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. - Security/Slack interactions: enforce channel/DM authorization and modal actor binding (`private_metadata.userId`) before enqueueing `block_action`/`view_submission`/`view_closed` system events, with regression coverage for unauthorized senders and missing/mismatched actor metadata. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey 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/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3.
docs/channels/groups.md+1 −0 modified@@ -184,6 +184,7 @@ Notes: - `groupPolicy` is separate from mention-gating (which requires @mentions). - WhatsApp/Telegram/Signal/iMessage/Microsoft Teams/Zalo: use `groupAllowFrom` (fallback: explicit `allowFrom`). +- DM pairing approvals (`*-allowFrom` store entries) apply to DM access only; group sender authorization stays explicit to group allowlists. - Discord: allowlist uses `channels.discord.guilds.<id>.channels`. - Slack: allowlist uses `channels.slack.channels`. - Matrix: allowlist uses `channels.matrix.groups` (room IDs, aliases, or names). Use `channels.matrix.groupAllowFrom` to restrict senders; per-room `users` allowlists are also supported.
extensions/mattermost/src/mattermost/monitor.authz.test.ts+37 −0 added@@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { resolveMattermostEffectiveAllowFromLists } from "./monitor.js"; + +describe("mattermost monitor authz", () => { + it("keeps DM allowlist merged with pairing-store entries", () => { + const resolved = resolveMattermostEffectiveAllowFromLists({ + dmPolicy: "pairing", + allowFrom: ["@trusted-user"], + groupAllowFrom: ["@group-owner"], + storeAllowFrom: ["user:attacker"], + }); + + expect(resolved.effectiveAllowFrom).toEqual(["trusted-user", "attacker"]); + }); + + it("uses explicit groupAllowFrom without pairing-store inheritance", () => { + const resolved = resolveMattermostEffectiveAllowFromLists({ + dmPolicy: "pairing", + allowFrom: ["@trusted-user"], + groupAllowFrom: ["@group-owner"], + storeAllowFrom: ["user:attacker"], + }); + + expect(resolved.effectiveGroupAllowFrom).toEqual(["group-owner"]); + }); + + it("does not inherit pairing-store entries into group allowlist", () => { + const resolved = resolveMattermostEffectiveAllowFromLists({ + dmPolicy: "pairing", + allowFrom: ["@trusted-user"], + storeAllowFrom: ["user:attacker"], + }); + + expect(resolved.effectiveAllowFrom).toEqual(["trusted-user", "attacker"]); + expect(resolved.effectiveGroupAllowFrom).toEqual(["trusted-user"]); + }); +});
extensions/mattermost/src/mattermost/monitor.ts+25 −9 modified@@ -18,6 +18,7 @@ import { isDangerousNameMatchingEnabled, resolveControlCommandGate, resolveDmGroupAccessWithLists, + resolveEffectiveAllowFromLists, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, resolveChannelMediaMaxBytes, @@ -150,6 +151,23 @@ function normalizeAllowList(entries: Array<string | number>): string[] { return Array.from(new Set(normalized)); } +export function resolveMattermostEffectiveAllowFromLists(params: { + allowFrom?: Array<string | number> | null; + groupAllowFrom?: Array<string | number> | null; + storeAllowFrom?: Array<string | number> | null; + dmPolicy?: string | null; +}): { + effectiveAllowFrom: string[]; + effectiveGroupAllowFrom: string[]; +} { + return resolveEffectiveAllowFromLists({ + allowFrom: normalizeAllowList(params.allowFrom ?? []), + groupAllowFrom: normalizeAllowList(params.groupAllowFrom ?? []), + storeAllowFrom: normalizeAllowList(params.storeAllowFrom ?? []), + dmPolicy: params.dmPolicy, + }); +} + function isSenderAllowed(params: { senderId: string; senderName?: string; @@ -400,20 +418,18 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} senderId; const rawText = post.message?.trim() || ""; const dmPolicy = account.config.dmPolicy ?? "pairing"; - const configAllowFrom = normalizeAllowList(account.config.allowFrom ?? []); - const configGroupAllowFrom = normalizeAllowList(account.config.groupAllowFrom ?? []); const storeAllowFrom = normalizeAllowList( dmPolicy === "allowlist" ? [] : await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []), ); - const effectiveAllowFrom = Array.from(new Set([...configAllowFrom, ...storeAllowFrom])); - const effectiveGroupAllowFrom = Array.from( - new Set([ - ...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom), - ...storeAllowFrom, - ]), - ); + const { effectiveAllowFrom, effectiveGroupAllowFrom } = + resolveMattermostEffectiveAllowFromLists({ + dmPolicy, + allowFrom: account.config.allowFrom, + groupAllowFrom: account.config.groupAllowFrom, + storeAllowFrom, + }); const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ cfg, surface: "mattermost",
src/channels/allow-from.test.ts+30 −5 modified@@ -1,10 +1,15 @@ import { describe, expect, it } from "vitest"; -import { firstDefined, isSenderIdAllowed, mergeAllowFromSources } from "./allow-from.js"; +import { + firstDefined, + isSenderIdAllowed, + mergeDmAllowFromSources, + resolveGroupAllowFromSources, +} from "./allow-from.js"; -describe("mergeAllowFromSources", () => { +describe("mergeDmAllowFromSources", () => { it("merges, trims, and filters empty values", () => { expect( - mergeAllowFromSources({ + mergeDmAllowFromSources({ allowFrom: [" line:user:abc ", "", 123], storeAllowFrom: [" ", "telegram:456"], }), @@ -13,7 +18,7 @@ describe("mergeAllowFromSources", () => { it("excludes pairing-store entries when dmPolicy is allowlist", () => { expect( - mergeAllowFromSources({ + mergeDmAllowFromSources({ allowFrom: ["+1111"], storeAllowFrom: ["+2222", "+3333"], dmPolicy: "allowlist", @@ -23,7 +28,7 @@ describe("mergeAllowFromSources", () => { it("keeps pairing-store entries for non-allowlist policies", () => { expect( - mergeAllowFromSources({ + mergeDmAllowFromSources({ allowFrom: ["+1111"], storeAllowFrom: ["+2222"], dmPolicy: "pairing", @@ -32,6 +37,26 @@ describe("mergeAllowFromSources", () => { }); }); +describe("resolveGroupAllowFromSources", () => { + it("prefers explicit group allowlist", () => { + expect( + resolveGroupAllowFromSources({ + allowFrom: ["owner"], + groupAllowFrom: ["group-owner", " group-admin "], + }), + ).toEqual(["group-owner", "group-admin"]); + }); + + it("falls back to DM allowlist when group allowlist is unset/empty", () => { + expect( + resolveGroupAllowFromSources({ + allowFrom: [" owner ", "", "owner2"], + groupAllowFrom: [], + }), + ).toEqual(["owner", "owner2"]); + }); +}); + describe("firstDefined", () => { it("returns the first non-undefined value", () => { expect(firstDefined(undefined, undefined, "x", "y")).toBe("x");
src/channels/allow-from.ts+13 −2 modified@@ -1,6 +1,6 @@ -export function mergeAllowFromSources(params: { +export function mergeDmAllowFromSources(params: { allowFrom?: Array<string | number>; - storeAllowFrom?: string[]; + storeAllowFrom?: Array<string | number>; dmPolicy?: string; }): string[] { const storeEntries = params.dmPolicy === "allowlist" ? [] : (params.storeAllowFrom ?? []); @@ -9,6 +9,17 @@ export function mergeAllowFromSources(params: { .filter(Boolean); } +export function resolveGroupAllowFromSources(params: { + allowFrom?: Array<string | number>; + groupAllowFrom?: Array<string | number>; +}): string[] { + const scoped = + params.groupAllowFrom && params.groupAllowFrom.length > 0 + ? params.groupAllowFrom + : (params.allowFrom ?? []); + return scoped.map((value) => String(value).trim()).filter(Boolean); +} + export function firstDefined<T>(...values: Array<T | undefined>) { for (const value of values) { if (typeof value !== "undefined") {
src/line/bot-access.ts+7 −3 modified@@ -1,4 +1,8 @@ -import { firstDefined, isSenderIdAllowed, mergeAllowFromSources } from "../channels/allow-from.js"; +import { + firstDefined, + isSenderIdAllowed, + mergeDmAllowFromSources, +} from "../channels/allow-from.js"; export type NormalizedAllowFrom = { entries: string[]; @@ -27,11 +31,11 @@ export const normalizeAllowFrom = (list?: Array<string | number>): NormalizedAll }; }; -export const normalizeAllowFromWithStore = (params: { +export const normalizeDmAllowFromWithStore = (params: { allowFrom?: Array<string | number>; storeAllowFrom?: string[]; dmPolicy?: string; -}): NormalizedAllowFrom => normalizeAllowFrom(mergeAllowFromSources(params)); +}): NormalizedAllowFrom => normalizeAllowFrom(mergeDmAllowFromSources(params)); export const isSenderAllowed = (params: { allow: NormalizedAllowFrom;
src/line/bot-handlers.test.ts+35 −0 modified@@ -182,6 +182,41 @@ describe("handleLineWebhookEvents", () => { expect(processMessage).toHaveBeenCalledTimes(1); }); + it("blocks group sender that is only present in pairing-store allowlist", async () => { + const processMessage = vi.fn(); + readAllowFromStoreMock.mockResolvedValueOnce(["user-paired"]); + const event = { + type: "message", + message: { id: "m3b", type: "text", text: "hi" }, + replyToken: "reply-token", + timestamp: Date.now(), + source: { type: "group", groupId: "group-1", userId: "user-paired" }, + mode: "active", + webhookEventId: "evt-3b", + deliveryContext: { isRedelivery: false }, + } as MessageEvent; + + await handleLineWebhookEvents([event], { + cfg: { + channels: { line: { groupPolicy: "allowlist", groupAllowFrom: ["user-owner"] } }, + }, + account: { + accountId: "default", + enabled: true, + channelAccessToken: "token", + channelSecret: "secret", + tokenSource: "config", + config: { groupPolicy: "allowlist", groupAllowFrom: ["user-owner"] }, + }, + runtime: createRuntime(), + mediaMaxBytes: 1, + processMessage, + }); + + expect(buildLineMessageContextMock).not.toHaveBeenCalled(); + expect(processMessage).not.toHaveBeenCalled(); + }); + it("blocks group messages when wildcard group config disables groups", async () => { const processMessage = vi.fn(); const event = {
src/line/bot-handlers.ts+10 −7 modified@@ -21,7 +21,12 @@ import { upsertChannelPairingRequest, } from "../pairing/pairing-store.js"; import type { RuntimeEnv } from "../runtime.js"; -import { firstDefined, isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js"; +import { + firstDefined, + isSenderAllowed, + normalizeAllowFrom, + normalizeDmAllowFromWithStore, +} from "./bot-access.js"; import { getLineSourceInfo, buildLineMessageContext, @@ -117,7 +122,7 @@ async function shouldProcessLineEvent( const dmPolicy = account.config.dmPolicy ?? "pairing"; const storeAllowFrom = await readChannelAllowFromStore("line").catch(() => []); - const effectiveDmAllow = normalizeAllowFromWithStore({ + const effectiveDmAllow = normalizeDmAllowFromWithStore({ allowFrom: account.config.allowFrom, storeAllowFrom, dmPolicy, @@ -132,11 +137,9 @@ async function shouldProcessLineEvent( account.config.groupAllowFrom, fallbackGroupAllowFrom, ); - const effectiveGroupAllow = normalizeAllowFromWithStore({ - allowFrom: groupAllowFrom, - storeAllowFrom, - dmPolicy, - }); + // Group authorization stays explicit to group allowlists and must not + // inherit DM pairing-store identities. + const effectiveGroupAllow = normalizeAllowFrom(groupAllowFrom); const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); const { groupPolicy, providerMissingFallbackApplied } = resolveAllowlistProviderRuntimeGroupPolicy({
src/security/dm-policy-shared.test.ts+5 −5 modified@@ -41,7 +41,7 @@ describe("security/dm-policy-shared", () => { storeAllowFrom: [" owner3 ", ""], }); expect(lists.effectiveAllowFrom).toEqual(["owner", "owner2", "owner3"]); - expect(lists.effectiveGroupAllowFrom).toEqual(["group:abc", "owner3"]); + expect(lists.effectiveGroupAllowFrom).toEqual(["group:abc"]); }); it("falls back to DM allowlist for groups when groupAllowFrom is empty", () => { @@ -51,7 +51,7 @@ describe("security/dm-policy-shared", () => { storeAllowFrom: [" owner2 "], }); expect(lists.effectiveAllowFrom).toEqual(["owner", "owner2"]); - expect(lists.effectiveGroupAllowFrom).toEqual(["owner", "owner2"]); + expect(lists.effectiveGroupAllowFrom).toEqual(["owner"]); }); it("excludes storeAllowFrom when dmPolicy is allowlist", () => { @@ -65,15 +65,15 @@ describe("security/dm-policy-shared", () => { expect(lists.effectiveGroupAllowFrom).toEqual(["group:abc"]); }); - it("includes storeAllowFrom when dmPolicy is pairing", () => { + it("keeps group allowlist explicit when dmPolicy is pairing", () => { const lists = resolveEffectiveAllowFromLists({ allowFrom: ["+1111"], groupAllowFrom: [], storeAllowFrom: ["+2222"], dmPolicy: "pairing", }); expect(lists.effectiveAllowFrom).toEqual(["+1111", "+2222"]); - expect(lists.effectiveGroupAllowFrom).toEqual(["+1111", "+2222"]); + expect(lists.effectiveGroupAllowFrom).toEqual(["+1111"]); }); it("resolves access + effective allowlists in one shared call", () => { @@ -89,7 +89,7 @@ describe("security/dm-policy-shared", () => { expect(resolved.decision).toBe("allow"); expect(resolved.reason).toBe("dmPolicy=pairing (allowlisted)"); expect(resolved.effectiveAllowFrom).toEqual(["owner", "paired-user"]); - expect(resolved.effectiveGroupAllowFrom).toEqual(["group:room", "paired-user"]); + expect(resolved.effectiveGroupAllowFrom).toEqual(["group:room"]); }); it("keeps allowlist mode strict in shared resolver (no pairing-store fallback)", () => {
src/security/dm-policy-shared.ts+16 −13 modified@@ -1,3 +1,4 @@ +import { mergeDmAllowFromSources, resolveGroupAllowFromSources } from "../channels/allow-from.js"; import type { ChannelId } from "../channels/plugins/types.js"; import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { normalizeStringEntries } from "../shared/string-normalization.js"; @@ -11,21 +12,23 @@ export function resolveEffectiveAllowFromLists(params: { effectiveAllowFrom: string[]; effectiveGroupAllowFrom: string[]; } { - const configAllowFrom = normalizeStringEntries( - Array.isArray(params.allowFrom) ? params.allowFrom : undefined, + const allowFrom = Array.isArray(params.allowFrom) ? params.allowFrom : undefined; + const groupAllowFrom = Array.isArray(params.groupAllowFrom) ? params.groupAllowFrom : undefined; + const storeAllowFrom = Array.isArray(params.storeAllowFrom) ? params.storeAllowFrom : undefined; + const effectiveAllowFrom = normalizeStringEntries( + mergeDmAllowFromSources({ + allowFrom, + storeAllowFrom, + dmPolicy: params.dmPolicy ?? undefined, + }), ); - const configGroupAllowFrom = normalizeStringEntries( - Array.isArray(params.groupAllowFrom) ? params.groupAllowFrom : undefined, + // Group auth is explicit (groupAllowFrom fallback allowFrom). Pairing store is DM-only. + const effectiveGroupAllowFrom = normalizeStringEntries( + resolveGroupAllowFromSources({ + allowFrom, + groupAllowFrom, + }), ); - const storeAllowFrom = - params.dmPolicy === "allowlist" - ? [] - : 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 }; }
src/telegram/bot-access.ts+7 −3 modified@@ -1,4 +1,8 @@ -import { firstDefined, isSenderIdAllowed, mergeAllowFromSources } from "../channels/allow-from.js"; +import { + firstDefined, + isSenderIdAllowed, + mergeDmAllowFromSources, +} from "../channels/allow-from.js"; import type { AllowlistMatch } from "../channels/allowlist-match.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; @@ -53,11 +57,11 @@ export const normalizeAllowFrom = (list?: Array<string | number>): NormalizedAll }; }; -export const normalizeAllowFromWithStore = (params: { +export const normalizeDmAllowFromWithStore = (params: { allowFrom?: Array<string | number>; storeAllowFrom?: string[]; dmPolicy?: string; -}): NormalizedAllowFrom => normalizeAllowFrom(mergeAllowFromSources(params)); +}): NormalizedAllowFrom => normalizeAllowFrom(mergeDmAllowFromSources(params)); export const isSenderAllowed = (params: { allow: NormalizedAllowFrom;
src/telegram/bot-handlers.ts+3 −3 modified@@ -28,7 +28,7 @@ import { resolveThreadSessionKeys } from "../routing/session-key.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { isSenderAllowed, - normalizeAllowFromWithStore, + normalizeDmAllowFromWithStore, type NormalizedAllowFrom, } from "./bot-access.js"; import type { TelegramMediaRef } from "./bot-message-context.js"; @@ -615,7 +615,7 @@ export const registerTelegramHandlers = ({ return { allowed: false, reason: "direct-disabled" }; } if (dmPolicy !== "open") { - const effectiveDmAllow = normalizeAllowFromWithStore({ + const effectiveDmAllow = normalizeDmAllowFromWithStore({ allowFrom, storeAllowFrom, dmPolicy, @@ -1273,7 +1273,7 @@ export const registerTelegramHandlers = ({ effectiveGroupAllow, hasGroupAllowOverride, } = eventAuthContext; - const effectiveDmAllow = normalizeAllowFromWithStore({ + const effectiveDmAllow = normalizeDmAllowFromWithStore({ allowFrom, storeAllowFrom, dmPolicy,
src/telegram/bot-message-context.ts+2 −2 modified@@ -40,7 +40,7 @@ import { firstDefined, isSenderAllowed, normalizeAllowFrom, - normalizeAllowFromWithStore, + normalizeDmAllowFromWithStore, } from "./bot-access.js"; import { buildGroupLabel, @@ -195,7 +195,7 @@ export const buildTelegramMessageContext = async ({ : null; const sessionKey = threadKeys?.sessionKey ?? baseSessionKey; const mentionRegexes = buildMentionRegexes(cfg, route.agentId); - const effectiveDmAllow = normalizeAllowFromWithStore({ allowFrom, storeAllowFrom, dmPolicy }); + const effectiveDmAllow = normalizeDmAllowFromWithStore({ allowFrom, storeAllowFrom, dmPolicy }); const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom); // Group sender checks are explicit and must not inherit DM pairing-store entries. const effectiveGroupAllow = normalizeAllowFrom(groupAllowOverride ?? groupAllowFrom);
src/telegram/bot-native-commands.ts+2 −2 modified@@ -41,7 +41,7 @@ import { resolveAgentRoute } from "../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; -import { isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js"; +import { isSenderAllowed, normalizeDmAllowFromWithStore } from "./bot-access.js"; import { buildCappedTelegramMenuCommands, buildPluginTelegramMenuCommands, @@ -251,7 +251,7 @@ async function resolveTelegramCommandAuth(params: { } } - const dmAllow = normalizeAllowFromWithStore({ + const dmAllow = normalizeDmAllowFromWithStore({ allowFrom: allowFrom, storeAllowFrom, dmPolicy: telegramCfg.dmPolicy ?? "pairing",
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- github.com/openclaw/openclaw/commit/64de4b6d6ae81e269ceb4ca16f53cda99ced967aghsapatchWEB
- github.com/openclaw/openclaw/commit/8bdda7a651c21e98faccdbbd73081e79cffe8be0ghsapatchWEB
- github.com/advisories/GHSA-wm8r-w8pf-2v6wghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-wm8r-w8pf-2v6wghsathird-party-advisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-31991ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-authorization-bypass-via-dm-pairing-store-leakage-in-signal-group-allowlistghsathird-party-advisoryWEB
News mentions
0No linked articles in our index yet.