openclaw/src/web/inbound/access-control.ts

228 lines
7.7 KiB
TypeScript

import { loadConfig } from "../../config/config.js";
import {
resolveOpenProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
} from "../../config/runtime-group-policy.js";
import { logVerbose } from "../../globals.js";
import { buildPairingReply } from "../../pairing/pairing-messages.js";
import {
readChannelAllowFromStore,
upsertChannelPairingRequest,
} from "../../pairing/pairing-store.js";
import { isSelfChatMode, normalizeE164 } from "../../utils.js";
import { resolveWhatsAppAccount } from "../accounts.js";
export type InboundAccessControlResult = {
allowed: boolean;
shouldMarkRead: boolean;
isSelfChat: boolean;
resolvedAccountId: string;
};
const PAIRING_REPLY_HISTORY_GRACE_MS = 30_000;
function resolveWhatsAppRuntimeGroupPolicy(params: {
providerConfigPresent: boolean;
groupPolicy?: "open" | "allowlist" | "disabled";
defaultGroupPolicy?: "open" | "allowlist" | "disabled";
}): {
groupPolicy: "open" | "allowlist" | "disabled";
providerMissingFallbackApplied: boolean;
} {
return resolveOpenProviderRuntimeGroupPolicy({
providerConfigPresent: params.providerConfigPresent,
groupPolicy: params.groupPolicy,
defaultGroupPolicy: params.defaultGroupPolicy,
});
}
export async function checkInboundAccessControl(params: {
accountId: string;
from: string;
selfE164: string | null;
senderE164: string | null;
group: boolean;
pushName?: string;
isFromMe: boolean;
messageTimestampMs?: number;
connectedAtMs?: number;
pairingGraceMs?: number;
sock: {
sendMessage: (jid: string, content: { text: string }) => Promise<unknown>;
};
remoteJid: string;
}): Promise<InboundAccessControlResult> {
const cfg = loadConfig();
const account = resolveWhatsAppAccount({
cfg,
accountId: params.accountId,
});
const dmPolicy = account.dmPolicy ?? "pairing";
const configuredAllowFrom = account.allowFrom;
const storeAllowFrom =
dmPolicy === "allowlist"
? []
: await readChannelAllowFromStore("whatsapp", process.env, account.accountId).catch(() => []);
// 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;
const groupAllowFrom =
account.groupAllowFrom ??
(configuredAllowFrom && configuredAllowFrom.length > 0 ? configuredAllowFrom : undefined);
const isSamePhone = params.from === params.selfE164;
const isSelfChat = account.selfChatMode ?? isSelfChatMode(params.selfE164, configuredAllowFrom);
const pairingGraceMs =
typeof params.pairingGraceMs === "number" && params.pairingGraceMs > 0
? params.pairingGraceMs
: PAIRING_REPLY_HISTORY_GRACE_MS;
const suppressPairingReply =
typeof params.connectedAtMs === "number" &&
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
// - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const { groupPolicy, providerMissingFallbackApplied } = resolveWhatsAppRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.whatsapp !== undefined,
groupPolicy: account.groupPolicy,
defaultGroupPolicy,
});
warnMissingProviderGroupPolicyFallbackOnce({
providerMissingFallbackApplied,
providerKey: "whatsapp",
accountId: account.accountId,
log: (message) => logVerbose(message),
});
if (params.group && groupPolicy === "disabled") {
logVerbose("Blocked group message (groupPolicy: disabled)");
return {
allowed: false,
shouldMarkRead: false,
isSelfChat,
resolvedAccountId: account.accountId,
};
}
if (params.group && groupPolicy === "allowlist") {
if (!groupAllowFrom || groupAllowFrom.length === 0) {
logVerbose("Blocked group message (groupPolicy: allowlist, no groupAllowFrom)");
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) {
if (params.isFromMe && !isSamePhone) {
logVerbose("Skipping outbound DM (fromMe); no pairing reply needed.");
return {
allowed: false,
shouldMarkRead: false,
isSelfChat,
resolvedAccountId: account.accountId,
};
}
if (dmPolicy === "disabled") {
logVerbose("Blocked dm (dmPolicy: disabled)");
return {
allowed: false,
shouldMarkRead: false,
isSelfChat,
resolvedAccountId: account.accountId,
};
}
if (dmPolicy !== "open" && !isSamePhone) {
const candidate = params.from;
const allowed =
dmHasWildcard ||
(normalizedAllowFrom.length > 0 && normalizedAllowFrom.includes(candidate));
if (!allowed) {
if (dmPolicy === "pairing") {
if (suppressPairingReply) {
logVerbose(`Skipping pairing reply for historical DM from ${candidate}.`);
} else {
const { code, created } = await upsertChannelPairingRequest({
channel: "whatsapp",
id: candidate,
accountId: account.accountId,
meta: { name: (params.pushName ?? "").trim() || undefined },
});
if (created) {
logVerbose(
`whatsapp pairing request sender=${candidate} name=${params.pushName ?? "unknown"}`,
);
try {
await params.sock.sendMessage(params.remoteJid, {
text: buildPairingReply({
channel: "whatsapp",
idLine: `Your WhatsApp phone number: ${candidate}`,
code,
}),
});
} catch (err) {
logVerbose(`whatsapp pairing reply failed for ${candidate}: ${String(err)}`);
}
}
}
} else {
logVerbose(`Blocked unauthorized sender ${candidate} (dmPolicy=${dmPolicy})`);
}
return {
allowed: false,
shouldMarkRead: false,
isSelfChat,
resolvedAccountId: account.accountId,
};
}
}
}
return {
allowed: true,
shouldMarkRead: true,
isSelfChat,
resolvedAccountId: account.accountId,
};
}
export const __testing = {
resolveWhatsAppRuntimeGroupPolicy,
};