mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
fix: enforce explicit group auth boundaries across channels
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
dmPolicy,
|
||||
readStore: (provider) => core.channel.pairing.readAllowFromStore(provider),
|
||||
});
|
||||
const effectiveAllowFrom = normalizeMatrixAllowList([...allowFrom, ...storeAllowFrom]);
|
||||
const storeAllowFrom =
|
||||
isDirectMessage
|
||||
? await readStoreAllowFromForDmPolicy({
|
||||
provider: "matrix",
|
||||
dmPolicy,
|
||||
readStore: (provider) => core.channel.pairing.readAllowFromStore(provider),
|
||||
})
|
||||
: [];
|
||||
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;
|
||||
|
||||
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})`,
|
||||
if (access.decision === "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 },
|
||||
);
|
||||
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)}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
if (dmPolicy !== "pairing") {
|
||||
logVerboseMessage(
|
||||
`matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
|
||||
);
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)", {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
formatTextWithAttachmentLinks,
|
||||
logInboundDrop,
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolveControlCommandGate,
|
||||
resolveDmGroupAccessWithCommandGate,
|
||||
resolveOutboundMediaUrls,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
@@ -120,11 +120,6 @@ 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,
|
||||
@@ -132,25 +127,33 @@ export async function handleNextcloudTalkInbound(params: {
|
||||
});
|
||||
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,
|
||||
|
||||
@@ -355,6 +355,7 @@ async function processMessageWithPipeline(params: {
|
||||
isGroup,
|
||||
dmPolicy,
|
||||
configuredAllowFrom: configAllowFrom,
|
||||
configuredGroupAllowFrom: groupAllowFrom,
|
||||
senderId,
|
||||
isSenderAllowed: isZaloSenderAllowed,
|
||||
readAllowFromStore: () => core.channel.pairing.readAllowFromStore("zalo"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
51
src/plugin-sdk/command-auth.test.ts
Normal file
51
src/plugin-sdk/command-auth.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveDmGroupAccessWithLists } from "../security/dm-policy-shared.js";
|
||||
|
||||
export type ResolveSenderCommandAuthorizationParams = {
|
||||
cfg: OpenClawConfig;
|
||||
@@ -6,6 +7,7 @@ export type ResolveSenderCommandAuthorizationParams = {
|
||||
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,14 +34,30 @@ 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;
|
||||
@@ -46,6 +65,7 @@ export async function resolveSenderCommandAuthorization(
|
||||
return {
|
||||
shouldComputeAuth,
|
||||
effectiveAllowFrom,
|
||||
effectiveGroupAllowFrom,
|
||||
senderAllowedForCommands,
|
||||
commandAuthorized,
|
||||
};
|
||||
|
||||
@@ -413,6 +413,7 @@ export {
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolveDmAllowState,
|
||||
resolveDmGroupAccessDecision,
|
||||
resolveDmGroupAccessWithCommandGate,
|
||||
resolveDmGroupAccessWithLists,
|
||||
resolveEffectiveAllowFromLists,
|
||||
} from "../security/dm-policy-shared.js";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -253,7 +253,7 @@ async function resolveTelegramCommandAuth(params: {
|
||||
|
||||
const dmAllow = normalizeDmAllowFromWithStore({
|
||||
allowFrom: allowFrom,
|
||||
storeAllowFrom,
|
||||
storeAllowFrom: isGroup ? [] : storeAllowFrom,
|
||||
dmPolicy: telegramCfg.dmPolicy ?? "pairing",
|
||||
});
|
||||
const senderAllowed = isSenderAllowed({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,8 +102,45 @@ 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,
|
||||
@@ -124,31 +148,6 @@ export async function checkInboundAccessControl(params: {
|
||||
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".
|
||||
if (!params.group) {
|
||||
@@ -161,7 +160,7 @@ export async function checkInboundAccessControl(params: {
|
||||
resolvedAccountId: account.accountId,
|
||||
};
|
||||
}
|
||||
if (dmPolicy === "disabled") {
|
||||
if (access.decision === "block" && access.reason === "dmPolicy=disabled") {
|
||||
logVerbose("Blocked dm (dmPolicy: disabled)");
|
||||
return {
|
||||
allowed: false,
|
||||
@@ -170,49 +169,49 @@ export async function checkInboundAccessControl(params: {
|
||||
resolvedAccountId: account.accountId,
|
||||
};
|
||||
}
|
||||
if (dmPolicy !== "open" && !isSamePhone) {
|
||||
if (access.decision === "pairing" && !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 (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,
|
||||
}),
|
||||
});
|
||||
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)}`);
|
||||
}
|
||||
}
|
||||
} 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user