fix: enforce explicit group auth boundaries across channels

This commit is contained in:
Peter Steinberger
2026-02-26 18:15:57 +01:00
parent d0d83a2020
commit 64de4b6d6a
20 changed files with 614 additions and 331 deletions

View File

@@ -502,6 +502,7 @@ export async function processMessage(
const dmPolicy = account.config.dmPolicy ?? "pairing"; const dmPolicy = account.config.dmPolicy ?? "pairing";
const groupPolicy = account.config.groupPolicy ?? "allowlist"; const groupPolicy = account.config.groupPolicy ?? "allowlist";
const configuredAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
const storeAllowFrom = await readStoreAllowFromForDmPolicy({ const storeAllowFrom = await readStoreAllowFromForDmPolicy({
provider: "bluebubbles", provider: "bluebubbles",
dmPolicy, dmPolicy,
@@ -511,7 +512,7 @@ export async function processMessage(
isGroup, isGroup,
dmPolicy, dmPolicy,
groupPolicy, groupPolicy,
allowFrom: account.config.allowFrom, allowFrom: configuredAllowFrom,
groupAllowFrom: account.config.groupAllowFrom, groupAllowFrom: account.config.groupAllowFrom,
storeAllowFrom, storeAllowFrom,
isSenderAllowed: (allowFrom) => isSenderAllowed: (allowFrom) =>
@@ -666,10 +667,11 @@ export async function processMessage(
// Command gating (parity with iMessage/WhatsApp) // Command gating (parity with iMessage/WhatsApp)
const useAccessGroups = config.commands?.useAccessGroups !== false; const useAccessGroups = config.commands?.useAccessGroups !== false;
const hasControlCmd = core.channel.text.hasControlCommand(messageText, config); const hasControlCmd = core.channel.text.hasControlCommand(messageText, config);
const commandDmAllowFrom = isGroup ? configuredAllowFrom : effectiveAllowFrom;
const ownerAllowedForCommands = const ownerAllowedForCommands =
effectiveAllowFrom.length > 0 commandDmAllowFrom.length > 0
? isAllowedBlueBubblesSender({ ? isAllowedBlueBubblesSender({
allowFrom: effectiveAllowFrom, allowFrom: commandDmAllowFrom,
sender: message.senderId, sender: message.senderId,
chatId: message.chatId ?? undefined, chatId: message.chatId ?? undefined,
chatGuid: message.chatGuid ?? undefined, chatGuid: message.chatGuid ?? undefined,
@@ -690,7 +692,7 @@ export async function processMessage(
const commandGate = resolveControlCommandGate({ const commandGate = resolveControlCommandGate({
useAccessGroups, useAccessGroups,
authorizers: [ authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands }, { configured: commandDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
], ],
allowTextCommands: true, allowTextCommands: true,

View File

@@ -15,6 +15,7 @@ import {
warnMissingProviderGroupPolicyFallbackOnce, warnMissingProviderGroupPolicyFallbackOnce,
requestBodyErrorToText, requestBodyErrorToText,
resolveMentionGatingWithBypass, resolveMentionGatingWithBypass,
resolveDmGroupAccessWithLists,
} from "openclaw/plugin-sdk"; } from "openclaw/plugin-sdk";
import { type ResolvedGoogleChatAccount } from "./accounts.js"; import { type ResolvedGoogleChatAccount } from "./accounts.js";
import { import {
@@ -503,14 +504,33 @@ async function processMessageWithPipeline(params: {
const dmPolicy = account.config.dm?.policy ?? "pairing"; const dmPolicy = account.config.dm?.policy ?? "pairing";
const configAllowFrom = (account.config.dm?.allowFrom ?? []).map((v) => String(v)); 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 shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config);
const storeAllowFrom = const storeAllowFrom =
!isGroup && dmPolicy !== "allowlist" && (dmPolicy !== "open" || shouldComputeAuth) !isGroup && dmPolicy !== "allowlist" && (dmPolicy !== "open" || shouldComputeAuth)
? await core.channel.pairing.readAllowFromStore("googlechat").catch(() => []) ? 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); 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 useAccessGroups = config.commands?.useAccessGroups !== false;
const senderAllowedForCommands = isSenderAllowed( const senderAllowedForCommands = isSenderAllowed(
senderId, 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 (!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)`); logVerbose(core, runtime, `Blocked Google Chat DM from ${senderId} (dmPolicy=disabled)`);
return; return;
} }
if (dmPolicy !== "open") { if (access.decision !== "allow") {
const allowed = senderAllowedForCommands; if (access.decision === "pairing") {
if (!allowed) { const { code, created } = await core.channel.pairing.upsertPairingRequest({
if (dmPolicy === "pairing") { channel: "googlechat",
const { code, created } = await core.channel.pairing.upsertPairingRequest({ id: senderId,
channel: "googlechat", meta: { name: senderName || undefined, email: senderEmail },
id: senderId, });
meta: { name: senderName || undefined, email: senderEmail }, if (created) {
}); logVerbose(core, runtime, `googlechat pairing request sender=${senderId}`);
if (created) { try {
logVerbose(core, runtime, `googlechat pairing request sender=${senderId}`); await sendGoogleChatMessage({
try { account,
await sendGoogleChatMessage({ space: spaceId,
account, text: core.channel.pairing.buildPairingReply({
space: spaceId, channel: "googlechat",
text: core.channel.pairing.buildPairingReply({ idLine: `Your Google Chat user id: ${senderId}`,
channel: "googlechat", code,
idLine: `Your Google Chat user id: ${senderId}`, }),
code, });
}), statusSink?.({ lastOutboundAt: Date.now() });
}); } catch (err) {
statusSink?.({ lastOutboundAt: Date.now() }); logVerbose(core, runtime, `pairing reply failed for ${senderId}: ${String(err)}`);
} 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;
} }
} }

View File

@@ -7,6 +7,7 @@ import {
logTypingFailure, logTypingFailure,
readStoreAllowFromForDmPolicy, readStoreAllowFromForDmPolicy,
resolveControlCommandGate, resolveControlCommandGate,
resolveDmGroupAccessWithLists,
type PluginRuntime, type PluginRuntime,
type RuntimeEnv, type RuntimeEnv,
type RuntimeLogger, type RuntimeLogger,
@@ -214,62 +215,83 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
} }
const senderName = await getMemberDisplayName(roomId, senderId); const senderName = await getMemberDisplayName(roomId, senderId);
const storeAllowFrom = await readStoreAllowFromForDmPolicy({ const storeAllowFrom =
provider: "matrix", isDirectMessage
dmPolicy, ? await readStoreAllowFromForDmPolicy({
readStore: (provider) => core.channel.pairing.readAllowFromStore(provider), provider: "matrix",
}); dmPolicy,
const effectiveAllowFrom = normalizeMatrixAllowList([...allowFrom, ...storeAllowFrom]); readStore: (provider) => core.channel.pairing.readAllowFromStore(provider),
})
: [];
const groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? []; const groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? [];
const effectiveGroupAllowFrom = normalizeMatrixAllowList(groupAllowFrom); const normalizedGroupAllowFrom = normalizeMatrixAllowList(groupAllowFrom);
const senderGroupPolicy =
groupPolicy === "disabled"
? "disabled"
: normalizedGroupAllowFrom.length > 0
? "allowlist"
: "open";
const access = resolveDmGroupAccessWithLists({
isGroup: isRoom,
dmPolicy,
groupPolicy: senderGroupPolicy,
allowFrom,
groupAllowFrom: normalizedGroupAllowFrom,
storeAllowFrom,
groupAllowFromFallbackToAllowFrom: false,
isSenderAllowed: (allowFrom) =>
resolveMatrixAllowListMatches({
allowList: normalizeMatrixAllowList(allowFrom),
userId: senderId,
}),
});
const effectiveAllowFrom = normalizeMatrixAllowList(access.effectiveAllowFrom);
const effectiveGroupAllowFrom = normalizeMatrixAllowList(access.effectiveGroupAllowFrom);
const groupAllowConfigured = effectiveGroupAllowFrom.length > 0; const groupAllowConfigured = effectiveGroupAllowFrom.length > 0;
if (isDirectMessage) { if (isDirectMessage) {
if (!dmEnabled || dmPolicy === "disabled") { if (!dmEnabled) {
return; return;
} }
if (dmPolicy !== "open") { if (access.decision !== "allow") {
const allowMatch = resolveMatrixAllowListMatch({ const allowMatch = resolveMatrixAllowListMatch({
allowList: effectiveAllowFrom, allowList: effectiveAllowFrom,
userId: senderId, userId: senderId,
}); });
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch); const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
if (!allowMatch.allowed) { if (access.decision === "pairing") {
if (dmPolicy === "pairing") { const { code, created } = await core.channel.pairing.upsertPairingRequest({
const { code, created } = await core.channel.pairing.upsertPairingRequest({ channel: "matrix",
channel: "matrix", id: senderId,
id: senderId, meta: { name: senderName },
meta: { name: senderName }, });
}); if (created) {
if (created) { logVerboseMessage(
logVerboseMessage( `matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${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 },
); );
try { } catch (err) {
await sendMessageMatrix( logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`);
`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") { } else {
logVerboseMessage( logVerboseMessage(
`matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`, `matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
); );
}
return;
} }
return;
} }
} }
@@ -288,7 +310,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
return; return;
} }
} }
if (isRoom && groupPolicy === "allowlist" && roomUsers.length === 0 && groupAllowConfigured) { if (isRoom && roomUsers.length === 0 && groupAllowConfigured && access.decision !== "allow") {
const groupAllowMatch = resolveMatrixAllowListMatch({ const groupAllowMatch = resolveMatrixAllowListMatch({
allowList: effectiveGroupAllowFrom, allowList: effectiveGroupAllowFrom,
userId: senderId, userId: senderId,

View File

@@ -390,10 +390,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const hasControlCommand = core.channel.text.hasControlCommand(rawText, cfg); const hasControlCommand = core.channel.text.hasControlCommand(rawText, cfg);
const isControlCommand = allowTextCommands && hasControlCommand; const isControlCommand = allowTextCommands && hasControlCommand;
const useAccessGroups = cfg.commands?.useAccessGroups !== false; const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const commandDmAllowFrom = kind === "direct" ? effectiveAllowFrom : normalizedAllowFrom;
const senderAllowedForCommands = isMattermostSenderAllowed({ const senderAllowedForCommands = isMattermostSenderAllowed({
senderId, senderId,
senderName, senderName,
allowFrom: effectiveAllowFrom, allowFrom: commandDmAllowFrom,
allowNameMatching, allowNameMatching,
}); });
const groupAllowedForCommands = isMattermostSenderAllowed({ const groupAllowedForCommands = isMattermostSenderAllowed({
@@ -405,7 +406,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const commandGate = resolveControlCommandGate({ const commandGate = resolveControlCommandGate({
useAccessGroups, useAccessGroups,
authorizers: [ authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }, { configured: commandDmAllowFrom.length > 0, allowed: senderAllowedForCommands },
{ {
configured: effectiveGroupAllowFrom.length > 0, configured: effectiveGroupAllowFrom.length > 0,
allowed: groupAllowedForCommands, allowed: groupAllowedForCommands,

View File

@@ -11,6 +11,7 @@ import {
resolveMentionGating, resolveMentionGating,
formatAllowlistMatchMeta, formatAllowlistMatchMeta,
resolveEffectiveAllowFromLists, resolveEffectiveAllowFromLists,
resolveDmGroupAccessWithLists,
type HistoryEntry, type HistoryEntry,
} from "openclaw/plugin-sdk"; } from "openclaw/plugin-sdk";
import { import {
@@ -146,53 +147,13 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
storeAllowFrom: storedAllowFrom, storeAllowFrom: storedAllowFrom,
dmPolicy, 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 defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const groupPolicy = const groupPolicy =
!isDirectMessage && msteamsCfg !isDirectMessage && msteamsCfg
? (msteamsCfg.groupPolicy ?? defaultGroupPolicy ?? "allowlist") ? (msteamsCfg.groupPolicy ?? defaultGroupPolicy ?? "allowlist")
: "disabled"; : "disabled";
const effectiveGroupAllowFrom = const effectiveGroupAllowFrom = resolvedAllowFromLists.effectiveGroupAllowFrom;
!isDirectMessage && msteamsCfg ? resolvedAllowFromLists.effectiveGroupAllowFrom : [];
const teamId = activity.channelData?.team?.id; const teamId = activity.channelData?.team?.id;
const teamName = activity.channelData?.team?.name; const teamName = activity.channelData?.team?.name;
const channelName = activity.channelData?.channel?.name; const channelName = activity.channelData?.channel?.name;
@@ -203,6 +164,61 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
conversationId, conversationId,
channelName, 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 (!isDirectMessage && msteamsCfg) {
if (groupPolicy === "disabled") { if (groupPolicy === "disabled") {
@@ -229,13 +245,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
}); });
return; return;
} }
if (effectiveGroupAllowFrom.length > 0) { if (effectiveGroupAllowFrom.length > 0 && access.decision !== "allow") {
const allowNameMatching = isDangerousNameMatchingEnabled(msteamsCfg);
const allowMatch = resolveMSTeamsAllowlistMatch({ const allowMatch = resolveMSTeamsAllowlistMatch({
allowFrom: effectiveGroupAllowFrom, allowFrom: effectiveGroupAllowFrom,
senderId, senderId,
senderName, senderName,
allowNameMatching, allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
}); });
if (!allowMatch.allowed) { if (!allowMatch.allowed) {
log.debug?.("dropping group message (not in groupAllowFrom)", { log.debug?.("dropping group message (not in groupAllowFrom)", {

View File

@@ -5,7 +5,7 @@ import {
formatTextWithAttachmentLinks, formatTextWithAttachmentLinks,
logInboundDrop, logInboundDrop,
readStoreAllowFromForDmPolicy, readStoreAllowFromForDmPolicy,
resolveControlCommandGate, resolveDmGroupAccessWithCommandGate,
resolveOutboundMediaUrls, resolveOutboundMediaUrls,
resolveAllowlistProviderRuntimeGroupPolicy, resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy, resolveDefaultGroupPolicy,
@@ -120,11 +120,6 @@ export async function handleNextcloudTalkInbound(params: {
} }
const roomAllowFrom = normalizeNextcloudTalkAllowlist(roomConfig?.allowFrom); 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({ const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
cfg: config as OpenClawConfig, cfg: config as OpenClawConfig,
@@ -132,25 +127,33 @@ export async function handleNextcloudTalkInbound(params: {
}); });
const useAccessGroups = const useAccessGroups =
(config.commands as Record<string, unknown> | undefined)?.useAccessGroups !== false; (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 hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig);
const commandGate = resolveControlCommandGate({ const access = resolveDmGroupAccessWithCommandGate({
useAccessGroups, isGroup,
authorizers: [ dmPolicy,
{ groupPolicy,
configured: (isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom).length > 0, allowFrom: configAllowFrom,
allowed: senderAllowedForCommands, groupAllowFrom: configGroupAllowFrom,
}, storeAllowFrom: storeAllowList,
], isSenderAllowed: (allowFrom) =>
allowTextCommands, resolveNextcloudTalkAllowlistMatch({
hasControlCommand, allowFrom,
senderId,
}).allowed,
command: {
useAccessGroups,
allowTextCommands,
hasControlCommand,
},
}); });
const commandAuthorized = commandGate.commandAuthorized; const commandAuthorized = access.commandAuthorized;
const effectiveGroupAllowFrom = access.effectiveGroupAllowFrom;
if (isGroup) { if (isGroup) {
if (access.decision !== "allow") {
runtime.log?.(`nextcloud-talk: drop group sender ${senderId} (reason=${access.reason})`);
return;
}
const groupAllow = resolveNextcloudTalkGroupAllow({ const groupAllow = resolveNextcloudTalkGroupAllow({
groupPolicy, groupPolicy,
outerAllowFrom: effectiveGroupAllowFrom, outerAllowFrom: effectiveGroupAllowFrom,
@@ -162,48 +165,36 @@ export async function handleNextcloudTalkInbound(params: {
return; return;
} }
} else { } else {
if (dmPolicy === "disabled") { if (access.decision !== "allow") {
runtime.log?.(`nextcloud-talk: drop DM sender=${senderId} (dmPolicy=disabled)`); if (access.decision === "pairing") {
return; const { code, created } = await core.channel.pairing.upsertPairingRequest({
} channel: CHANNEL_ID,
if (dmPolicy !== "open") { id: senderId,
const dmAllowed = resolveNextcloudTalkAllowlistMatch({ meta: { name: senderName || undefined },
allowFrom: effectiveAllowFrom, });
senderId, if (created) {
}).allowed; try {
if (!dmAllowed) { await sendMessageNextcloudTalk(
if (dmPolicy === "pairing") { roomToken,
const { code, created } = await core.channel.pairing.upsertPairingRequest({ core.channel.pairing.buildPairingReply({
channel: CHANNEL_ID, channel: CHANNEL_ID,
id: senderId, idLine: `Your Nextcloud user id: ${senderId}`,
meta: { name: senderName || undefined }, code,
}); }),
if (created) { { accountId: account.accountId },
try { );
await sendMessageNextcloudTalk( statusSink?.({ lastOutboundAt: Date.now() });
roomToken, } catch (err) {
core.channel.pairing.buildPairingReply({ runtime.error?.(`nextcloud-talk: pairing reply failed for ${senderId}: ${String(err)}`);
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({ logInboundDrop({
log: (message) => runtime.log?.(message), log: (message) => runtime.log?.(message),
channel: CHANNEL_ID, channel: CHANNEL_ID,

View File

@@ -355,6 +355,7 @@ async function processMessageWithPipeline(params: {
isGroup, isGroup,
dmPolicy, dmPolicy,
configuredAllowFrom: configAllowFrom, configuredAllowFrom: configAllowFrom,
configuredGroupAllowFrom: groupAllowFrom,
senderId, senderId,
isSenderAllowed: isZaloSenderAllowed, isSenderAllowed: isZaloSenderAllowed,
readAllowFromStore: () => core.channel.pairing.readAllowFromStore("zalo"), readAllowFromStore: () => core.channel.pairing.readAllowFromStore("zalo"),

View File

@@ -256,10 +256,11 @@ export function resolveIMessageInboundDecision(params: {
const canDetectMention = mentionRegexes.length > 0; const canDetectMention = mentionRegexes.length > 0;
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false; const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
const commandDmAllowFrom = isGroup ? params.allowFrom : effectiveDmAllowFrom;
const ownerAllowedForCommands = const ownerAllowedForCommands =
effectiveDmAllowFrom.length > 0 commandDmAllowFrom.length > 0
? isAllowedIMessageSender({ ? isAllowedIMessageSender({
allowFrom: effectiveDmAllowFrom, allowFrom: commandDmAllowFrom,
sender, sender,
chatId, chatId,
chatGuid, chatGuid,
@@ -280,7 +281,7 @@ export function resolveIMessageInboundDecision(params: {
const commandGate = resolveControlCommandGate({ const commandGate = resolveControlCommandGate({
useAccessGroups, useAccessGroups,
authorizers: [ authorizers: [
{ configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands }, { configured: commandDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
], ],
allowTextCommands: true, allowTextCommands: true,

View File

@@ -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"]);
});
});

View File

@@ -1,4 +1,5 @@
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js";
import { resolveDmGroupAccessWithLists } from "../security/dm-policy-shared.js";
export type ResolveSenderCommandAuthorizationParams = { export type ResolveSenderCommandAuthorizationParams = {
cfg: OpenClawConfig; cfg: OpenClawConfig;
@@ -6,6 +7,7 @@ export type ResolveSenderCommandAuthorizationParams = {
isGroup: boolean; isGroup: boolean;
dmPolicy: string; dmPolicy: string;
configuredAllowFrom: string[]; configuredAllowFrom: string[];
configuredGroupAllowFrom?: string[];
senderId: string; senderId: string;
isSenderAllowed: (senderId: string, allowFrom: string[]) => boolean; isSenderAllowed: (senderId: string, allowFrom: string[]) => boolean;
readAllowFromStore: () => Promise<string[]>; readAllowFromStore: () => Promise<string[]>;
@@ -21,6 +23,7 @@ export async function resolveSenderCommandAuthorization(
): Promise<{ ): Promise<{
shouldComputeAuth: boolean; shouldComputeAuth: boolean;
effectiveAllowFrom: string[]; effectiveAllowFrom: string[];
effectiveGroupAllowFrom: string[];
senderAllowedForCommands: boolean; senderAllowedForCommands: boolean;
commandAuthorized: boolean | undefined; commandAuthorized: boolean | undefined;
}> { }> {
@@ -31,14 +34,30 @@ export async function resolveSenderCommandAuthorization(
(params.dmPolicy !== "open" || shouldComputeAuth) (params.dmPolicy !== "open" || shouldComputeAuth)
? await params.readAllowFromStore().catch(() => []) ? 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 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 const commandAuthorized = shouldComputeAuth
? params.resolveCommandAuthorizedFromAuthorizers({ ? params.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups, useAccessGroups,
authorizers: [ authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }, { configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands },
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
], ],
}) })
: undefined; : undefined;
@@ -46,6 +65,7 @@ export async function resolveSenderCommandAuthorization(
return { return {
shouldComputeAuth, shouldComputeAuth,
effectiveAllowFrom, effectiveAllowFrom,
effectiveGroupAllowFrom,
senderAllowedForCommands, senderAllowedForCommands,
commandAuthorized, commandAuthorized,
}; };

View File

@@ -413,6 +413,7 @@ export {
readStoreAllowFromForDmPolicy, readStoreAllowFromForDmPolicy,
resolveDmAllowState, resolveDmAllowState,
resolveDmGroupAccessDecision, resolveDmGroupAccessDecision,
resolveDmGroupAccessWithCommandGate,
resolveDmGroupAccessWithLists, resolveDmGroupAccessWithLists,
resolveEffectiveAllowFromLists, resolveEffectiveAllowFromLists,
} from "../security/dm-policy-shared.js"; } from "../security/dm-policy-shared.js";

View File

@@ -3,6 +3,7 @@ import {
DM_GROUP_ACCESS_REASON, DM_GROUP_ACCESS_REASON,
readStoreAllowFromForDmPolicy, readStoreAllowFromForDmPolicy,
resolveDmAllowState, resolveDmAllowState,
resolveDmGroupAccessWithCommandGate,
resolveDmGroupAccessDecision, resolveDmGroupAccessDecision,
resolveDmGroupAccessWithLists, resolveDmGroupAccessWithLists,
resolveEffectiveAllowFromLists, resolveEffectiveAllowFromLists,
@@ -134,6 +135,66 @@ describe("security/dm-policy-shared", () => {
expect(resolved.effectiveGroupAllowFrom).toEqual(["group:room"]); 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)", () => { it("keeps allowlist mode strict in shared resolver (no pairing-store fallback)", () => {
const resolved = resolveDmGroupAccessWithLists({ const resolved = resolveDmGroupAccessWithLists({
isGroup: false, isGroup: false,

View File

@@ -1,4 +1,5 @@
import { mergeDmAllowFromSources, resolveGroupAllowFromSources } from "../channels/allow-from.js"; import { mergeDmAllowFromSources, resolveGroupAllowFromSources } from "../channels/allow-from.js";
import { resolveControlCommandGate } from "../channels/command-gating.js";
import type { ChannelId } from "../channels/plugins/types.js"; import type { ChannelId } from "../channels/plugins/types.js";
import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
import { normalizeStringEntries } from "../shared/string-normalization.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: { export async function resolveDmAllowState(params: {
provider: ChannelId; provider: ChannelId;
allowFrom?: Array<string | number> | null; allowFrom?: Array<string | number> | null;

View File

@@ -560,13 +560,14 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
} }
const useAccessGroups = deps.cfg.commands?.useAccessGroups !== false; 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 groupAllowedForCommands = isSignalSenderAllowed(sender, effectiveGroupAllow);
const hasControlCommandInMessage = hasControlCommand(messageText, deps.cfg); const hasControlCommandInMessage = hasControlCommand(messageText, deps.cfg);
const commandGate = resolveControlCommandGate({ const commandGate = resolveControlCommandGate({
useAccessGroups, useAccessGroups,
authorizers: [ authorizers: [
{ configured: effectiveDmAllow.length > 0, allowed: ownerAllowedForCommands }, { configured: commandDmAllow.length > 0, allowed: ownerAllowedForCommands },
{ configured: effectiveGroupAllow.length > 0, allowed: groupAllowedForCommands }, { configured: effectiveGroupAllow.length > 0, allowed: groupAllowedForCommands },
], ],
allowTextCommands: true, allowTextCommands: true,

View File

@@ -9,12 +9,19 @@ import {
import { resolveSlackChannelConfig } from "./channel-config.js"; import { resolveSlackChannelConfig } from "./channel-config.js";
import { normalizeSlackChannelType, type SlackMonitorContext } from "./context.js"; import { normalizeSlackChannelType, type SlackMonitorContext } from "./context.js";
export async function resolveSlackEffectiveAllowFrom(ctx: SlackMonitorContext) { export async function resolveSlackEffectiveAllowFrom(
const storeAllowFrom = await readStoreAllowFromForDmPolicy({ ctx: SlackMonitorContext,
provider: "slack", options?: { includePairingStore?: boolean },
dmPolicy: ctx.dmPolicy, ) {
readStore: (provider) => readChannelAllowFromStore(provider), 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 allowFrom = normalizeAllowList([...ctx.allowFrom, ...storeAllowFrom]);
const allowFromLower = normalizeAllowListLower(allowFrom); const allowFromLower = normalizeAllowListLower(allowFrom);
return { allowFrom, allowFromLower }; return { allowFrom, allowFromLower };
@@ -99,15 +106,15 @@ export async function authorizeSlackSystemEventSender(params: {
.catch(() => ({})); .catch(() => ({}));
const senderName = senderInfo.name; const senderName = senderInfo.name;
const resolveAllowFromLower = async () => const resolveAllowFromLower = async (includePairingStore = false) =>
(await resolveSlackEffectiveAllowFrom(params.ctx)).allowFromLower; (await resolveSlackEffectiveAllowFrom(params.ctx, { includePairingStore })).allowFromLower;
if (channelType === "im") { if (channelType === "im") {
if (!params.ctx.dmEnabled || params.ctx.dmPolicy === "disabled") { if (!params.ctx.dmEnabled || params.ctx.dmPolicy === "disabled") {
return { allowed: false, reason: "dm-disabled", channelType, channelName }; return { allowed: false, reason: "dm-disabled", channelType, channelName };
} }
if (params.ctx.dmPolicy !== "open") { if (params.ctx.dmPolicy !== "open") {
const allowFromLower = await resolveAllowFromLower(); const allowFromLower = await resolveAllowFromLower(true);
const senderAllowListed = isSlackSenderAllowListed({ const senderAllowListed = isSlackSenderAllowListed({
allowListLower: allowFromLower, allowListLower: allowFromLower,
senderId, senderId,
@@ -126,7 +133,7 @@ export async function authorizeSlackSystemEventSender(params: {
} else if (!channelId) { } else if (!channelId) {
// No channel context. Apply allowFrom if configured so we fail closed // No channel context. Apply allowFrom if configured so we fail closed
// for privileged interactive events when owner allowlist is present. // for privileged interactive events when owner allowlist is present.
const allowFromLower = await resolveAllowFromLower(); const allowFromLower = await resolveAllowFromLower(false);
if (allowFromLower.length > 0) { if (allowFromLower.length > 0) {
const senderAllowListed = isSlackSenderAllowListed({ const senderAllowListed = isSlackSenderAllowListed({
allowListLower: allowFromLower, allowListLower: allowFromLower,

View File

@@ -127,7 +127,9 @@ export async function prepareSlackMessage(params: {
return null; return null;
} }
const { allowFromLower } = await resolveSlackEffectiveAllowFrom(ctx); const { allowFromLower } = await resolveSlackEffectiveAllowFrom(ctx, {
includePairingStore: isDirectMessage,
});
if (isDirectMessage) { if (isDirectMessage) {
const directUserId = message.user; const directUserId = message.user;

View File

@@ -336,11 +336,14 @@ export async function registerSlackMonitorSlashCommands(params: {
return; return;
} }
const storeAllowFrom = await readStoreAllowFromForDmPolicy({ const storeAllowFrom =
provider: "slack", isDirectMessage
dmPolicy: ctx.dmPolicy, ? await readStoreAllowFromForDmPolicy({
readStore: (provider) => readChannelAllowFromStore(provider), provider: "slack",
}); dmPolicy: ctx.dmPolicy,
readStore: (provider) => readChannelAllowFromStore(provider),
})
: [];
const effectiveAllowFrom = normalizeAllowList([...ctx.allowFrom, ...storeAllowFrom]); const effectiveAllowFrom = normalizeAllowList([...ctx.allowFrom, ...storeAllowFrom]);
const effectiveAllowFromLower = normalizeAllowListLower(effectiveAllowFrom); const effectiveAllowFromLower = normalizeAllowListLower(effectiveAllowFrom);

View File

@@ -253,7 +253,7 @@ async function resolveTelegramCommandAuth(params: {
const dmAllow = normalizeDmAllowFromWithStore({ const dmAllow = normalizeDmAllowFromWithStore({
allowFrom: allowFrom, allowFrom: allowFrom,
storeAllowFrom, storeAllowFrom: isGroup ? [] : storeAllowFrom,
dmPolicy: telegramCfg.dmPolicy ?? "pairing", dmPolicy: telegramCfg.dmPolicy ?? "pairing",
}); });
const senderAllowed = isSenderAllowed({ const senderAllowed = isSenderAllowed({

View File

@@ -27,7 +27,10 @@ import type { getChildLogger } from "../../../logging.js";
import { getAgentScopedMediaLocalRoots } from "../../../media/local-roots.js"; import { getAgentScopedMediaLocalRoots } from "../../../media/local-roots.js";
import { readChannelAllowFromStore } from "../../../pairing/pairing-store.js"; import { readChannelAllowFromStore } from "../../../pairing/pairing-store.js";
import type { resolveAgentRoute } from "../../../routing/resolve-route.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 { jidToE164, normalizeE164 } from "../../../utils.js";
import { resolveWhatsAppAccount } from "../../accounts.js"; import { resolveWhatsAppAccount } from "../../accounts.js";
import { newConnectionId } from "../../reconnect.js"; import { newConnectionId } from "../../reconnect.js";
@@ -49,15 +52,6 @@ export type GroupHistoryEntry = {
senderJid?: string; 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: { async function resolveWhatsAppCommandAuthorized(params: {
cfg: ReturnType<typeof loadConfig>; cfg: ReturnType<typeof loadConfig>;
msg: WebInboundMsg; msg: WebInboundMsg;
@@ -77,38 +71,49 @@ async function resolveWhatsAppCommandAuthorized(params: {
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.msg.accountId }); const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.msg.accountId });
const dmPolicy = account.dmPolicy ?? "pairing"; const dmPolicy = account.dmPolicy ?? "pairing";
const groupPolicy = account.groupPolicy ?? "allowlist";
const configuredAllowFrom = account.allowFrom ?? []; const configuredAllowFrom = account.allowFrom ?? [];
const configuredGroupAllowFrom = const configuredGroupAllowFrom =
account.groupAllowFrom ?? (configuredAllowFrom.length > 0 ? configuredAllowFrom : undefined); account.groupAllowFrom ?? (configuredAllowFrom.length > 0 ? configuredAllowFrom : undefined);
if (isGroup) { const storeAllowFrom =
if (!configuredGroupAllowFrom || configuredGroupAllowFrom.length === 0) { isGroup
return false; ? []
} : await readStoreAllowFromForDmPolicy({
if (configuredGroupAllowFrom.some((v) => String(v).trim() === "*")) { provider: "whatsapp",
return true; dmPolicy,
} readStore: (provider) =>
return normalizeAllowFromE164(configuredGroupAllowFrom).includes(senderE164); readChannelAllowFromStore(provider, process.env, params.msg.accountId),
} });
const dmAllowFrom =
const storeAllowFrom = await readStoreAllowFromForDmPolicy({ configuredAllowFrom.length > 0
provider: "whatsapp", ? configuredAllowFrom
dmPolicy,
readStore: (provider) => readChannelAllowFromStore(provider, process.env, params.msg.accountId),
});
const combinedAllowFrom = Array.from(
new Set([...(configuredAllowFrom ?? []), ...storeAllowFrom]),
);
const allowFrom =
combinedAllowFrom.length > 0
? combinedAllowFrom
: params.msg.selfE164 : params.msg.selfE164
? [params.msg.selfE164] ? [params.msg.selfE164]
: []; : [];
if (allowFrom.some((v) => String(v).trim() === "*")) { const access = resolveDmGroupAccessWithCommandGate({
return true; isGroup,
} dmPolicy,
return normalizeAllowFromE164(allowFrom).includes(senderE164); 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: { export async function processMessage(params: {

View File

@@ -10,7 +10,10 @@ import {
readChannelAllowFromStore, readChannelAllowFromStore,
upsertChannelPairingRequest, upsertChannelPairingRequest,
} from "../../pairing/pairing-store.js"; } 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 { isSelfChatMode, normalizeE164 } from "../../utils.js";
import { resolveWhatsAppAccount } from "../accounts.js"; import { resolveWhatsAppAccount } from "../accounts.js";
@@ -60,22 +63,18 @@ export async function checkInboundAccessControl(params: {
accountId: params.accountId, accountId: params.accountId,
}); });
const dmPolicy = account.dmPolicy ?? "pairing"; const dmPolicy = account.dmPolicy ?? "pairing";
const configuredAllowFrom = account.allowFrom; const configuredAllowFrom = account.allowFrom ?? [];
const storeAllowFrom = await readStoreAllowFromForDmPolicy({ const storeAllowFrom = await readStoreAllowFromForDmPolicy({
provider: "whatsapp", provider: "whatsapp",
dmPolicy, dmPolicy,
readStore: (provider) => readChannelAllowFromStore(provider, process.env, account.accountId), readStore: (provider) => readChannelAllowFromStore(provider, process.env, account.accountId),
}); });
// Without user config, default to self-only DM access so the owner can talk to themselves. // 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 = const defaultAllowFrom =
combinedAllowFrom.length === 0 && params.selfE164 ? [params.selfE164] : undefined; configuredAllowFrom.length === 0 && params.selfE164 ? [params.selfE164] : [];
const allowFrom = combinedAllowFrom.length > 0 ? combinedAllowFrom : defaultAllowFrom; const dmAllowFrom = configuredAllowFrom.length > 0 ? configuredAllowFrom : defaultAllowFrom;
const groupAllowFrom = const groupAllowFrom =
account.groupAllowFrom ?? account.groupAllowFrom ?? (configuredAllowFrom.length > 0 ? configuredAllowFrom : undefined);
(configuredAllowFrom && configuredAllowFrom.length > 0 ? configuredAllowFrom : undefined);
const isSamePhone = params.from === params.selfE164; const isSamePhone = params.from === params.selfE164;
const isSelfChat = account.selfChatMode ?? isSelfChatMode(params.selfE164, configuredAllowFrom); const isSelfChat = account.selfChatMode ?? isSelfChatMode(params.selfE164, configuredAllowFrom);
const pairingGraceMs = const pairingGraceMs =
@@ -87,18 +86,6 @@ export async function checkInboundAccessControl(params: {
typeof params.messageTimestampMs === "number" && typeof params.messageTimestampMs === "number" &&
params.messageTimestampMs < params.connectedAtMs - pairingGraceMs; 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: // Group policy filtering:
// - "open": groups bypass allowFrom, only mention-gating applies // - "open": groups bypass allowFrom, only mention-gating applies
// - "disabled": block all group messages entirely // - "disabled": block all group messages entirely
@@ -115,8 +102,45 @@ export async function checkInboundAccessControl(params: {
accountId: account.accountId, accountId: account.accountId,
log: (message) => logVerbose(message), log: (message) => logVerbose(message),
}); });
if (params.group && groupPolicy === "disabled") { const normalizedDmSender = normalizeE164(params.from);
logVerbose("Blocked group message (groupPolicy: disabled)"); 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 { return {
allowed: false, allowed: false,
shouldMarkRead: false, shouldMarkRead: false,
@@ -124,31 +148,6 @@ export async function checkInboundAccessControl(params: {
resolvedAccountId: account.accountId, resolvedAccountId: account.accountId,
}; };
} }
if (params.group && groupPolicy === "allowlist") {
if (!groupAllowFrom || groupAllowFrom.length === 0) {
logVerbose("Blocked group message (groupPolicy: allowlist, no groupAllowFrom)");
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)`,
);
return {
allowed: false,
shouldMarkRead: false,
isSelfChat,
resolvedAccountId: account.accountId,
};
}
}
// DM access control (secure defaults): "pairing" (default) / "allowlist" / "open" / "disabled". // DM access control (secure defaults): "pairing" (default) / "allowlist" / "open" / "disabled".
if (!params.group) { if (!params.group) {
@@ -161,7 +160,7 @@ export async function checkInboundAccessControl(params: {
resolvedAccountId: account.accountId, resolvedAccountId: account.accountId,
}; };
} }
if (dmPolicy === "disabled") { if (access.decision === "block" && access.reason === "dmPolicy=disabled") {
logVerbose("Blocked dm (dmPolicy: disabled)"); logVerbose("Blocked dm (dmPolicy: disabled)");
return { return {
allowed: false, allowed: false,
@@ -170,49 +169,49 @@ export async function checkInboundAccessControl(params: {
resolvedAccountId: account.accountId, resolvedAccountId: account.accountId,
}; };
} }
if (dmPolicy !== "open" && !isSamePhone) { if (access.decision === "pairing" && !isSamePhone) {
const candidate = params.from; const candidate = params.from;
const allowed = if (suppressPairingReply) {
dmHasWildcard || logVerbose(`Skipping pairing reply for historical DM from ${candidate}.`);
(normalizedAllowFrom.length > 0 && normalizedAllowFrom.includes(candidate)); } else {
if (!allowed) { const { code, created } = await upsertChannelPairingRequest({
if (dmPolicy === "pairing") { channel: "whatsapp",
if (suppressPairingReply) { id: candidate,
logVerbose(`Skipping pairing reply for historical DM from ${candidate}.`); accountId: account.accountId,
} else { meta: { name: (params.pushName ?? "").trim() || undefined },
const { code, created } = await upsertChannelPairingRequest({ });
channel: "whatsapp", if (created) {
id: candidate, logVerbose(
accountId: account.accountId, `whatsapp pairing request sender=${candidate} name=${params.pushName ?? "unknown"}`,
meta: { name: (params.pushName ?? "").trim() || undefined }, );
try {
await params.sock.sendMessage(params.remoteJid, {
text: buildPairingReply({
channel: "whatsapp",
idLine: `Your WhatsApp phone number: ${candidate}`,
code,
}),
}); });
if (created) { } catch (err) {
logVerbose( logVerbose(`whatsapp pairing reply failed for ${candidate}: ${String(err)}`);
`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 {
allowed: false,
shouldMarkRead: false,
isSelfChat,
resolvedAccountId: account.accountId,
};
}
if (access.decision !== "allow") {
logVerbose(`Blocked unauthorized sender ${params.from} (dmPolicy=${dmPolicy})`);
return {
allowed: false,
shouldMarkRead: false,
isSelfChat,
resolvedAccountId: account.accountId,
};
} }
} }