security: WhatsApp incident remediation fixes
FIX-1.1: Enforce outbound allowlist for explicit WhatsApp sends - Added allowlist validation in explicit mode in resolveTarget - Added --allow-unlisted CLI flag for emergency overrides - Groups always allowed (no allowlist check needed) FIX-1.4: Heartbeat delivery hard-gating - Added requireExplicitTarget config option for heartbeat - When enabled, heartbeat delivery requires explicit to parameter - Returns channel: 'none' with reason: 'require-explicit' otherwise FIX-3.2: Delivery context trust boundary - Added isSenderTrustedForDeliveryContext() helper - Only update delivery context from allowlisted senders - Prevents untrusted inbound from setting routing targets Tests: All new tests passing (targets: 25/25, delivery-context: 10/10)
This commit is contained in:
parent
735aea9efa
commit
fc379a1332
@ -279,7 +279,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
|||||||
chunkerMode: "text",
|
chunkerMode: "text",
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
pollMaxOptions: 12,
|
pollMaxOptions: 12,
|
||||||
resolveTarget: ({ to, allowFrom, mode }) => {
|
resolveTarget: ({ to, allowFrom, mode, allowUnlisted }) => {
|
||||||
const trimmed = to?.trim() ?? "";
|
const trimmed = to?.trim() ?? "";
|
||||||
const allowListRaw = (allowFrom ?? []).map((entry) => String(entry).trim()).filter(Boolean);
|
const allowListRaw = (allowFrom ?? []).map((entry) => String(entry).trim()).filter(Boolean);
|
||||||
const hasWildcard = allowListRaw.includes("*");
|
const hasWildcard = allowListRaw.includes("*");
|
||||||
@ -302,6 +302,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// Groups are always allowed (no allowlist check needed)
|
||||||
if (isWhatsAppGroupJid(normalizedTo)) {
|
if (isWhatsAppGroupJid(normalizedTo)) {
|
||||||
return { ok: true, to: normalizedTo };
|
return { ok: true, to: normalizedTo };
|
||||||
}
|
}
|
||||||
@ -314,6 +315,28 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
|||||||
}
|
}
|
||||||
return { ok: true, to: allowList[0] };
|
return { ok: true, to: allowList[0] };
|
||||||
}
|
}
|
||||||
|
// For explicit mode - validate allowlist unless override is set
|
||||||
|
if (mode === "explicit") {
|
||||||
|
// Wildcard or empty allowlist: allow any target
|
||||||
|
if (hasWildcard || allowList.length === 0) {
|
||||||
|
return { ok: true, to: normalizedTo };
|
||||||
|
}
|
||||||
|
// Target is in allowlist: allow
|
||||||
|
if (allowList.includes(normalizedTo)) {
|
||||||
|
return { ok: true, to: normalizedTo };
|
||||||
|
}
|
||||||
|
// Target not in allowlist: require explicit override
|
||||||
|
if (allowUnlisted) {
|
||||||
|
return { ok: true, to: normalizedTo };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: new Error(
|
||||||
|
`Target ${normalizedTo} not in WhatsApp allowlist (channels.whatsapp.allowFrom). ` +
|
||||||
|
`Add the target to your allowlist, or use --allow-unlisted to override.`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
return { ok: true, to: normalizedTo };
|
return { ok: true, to: normalizedTo };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -32,7 +32,12 @@ import { normalizeChatType } from "../../channels/chat-type.js";
|
|||||||
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
||||||
import { formatInboundBodyWithSenderMeta } from "./inbound-sender-meta.js";
|
import { formatInboundBodyWithSenderMeta } from "./inbound-sender-meta.js";
|
||||||
import { normalizeInboundTextNewlines } from "./inbound-text.js";
|
import { normalizeInboundTextNewlines } from "./inbound-text.js";
|
||||||
import { normalizeSessionDeliveryFields } from "../../utils/delivery-context.js";
|
import {
|
||||||
|
isSenderTrustedForDeliveryContext,
|
||||||
|
normalizeSessionDeliveryFields,
|
||||||
|
} from "../../utils/delivery-context.js";
|
||||||
|
import { getChannelDock } from "../../channels/dock.js";
|
||||||
|
import { normalizeAnyChannelId } from "../../channels/registry.js";
|
||||||
|
|
||||||
export type SessionInitResult = {
|
export type SessionInitResult = {
|
||||||
sessionCtx: TemplateContext;
|
sessionCtx: TemplateContext;
|
||||||
@ -233,12 +238,36 @@ export async function initSessionState(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseEntry = !isNewSession && freshEntry ? entry : undefined;
|
const baseEntry = !isNewSession && freshEntry ? entry : undefined;
|
||||||
|
|
||||||
|
// FIX-3.2: Validate sender against allowlist before updating delivery context.
|
||||||
|
// This prevents routing replies to unknown recipients from untrusted senders.
|
||||||
|
const inboundChannel = normalizeAnyChannelId(
|
||||||
|
ctx.OriginatingChannel ?? ctx.Provider ?? ctx.Surface,
|
||||||
|
);
|
||||||
|
const channelDock = inboundChannel ? getChannelDock(inboundChannel) : undefined;
|
||||||
|
const inboundAllowFrom = channelDock?.config?.resolveAllowFrom
|
||||||
|
? channelDock.config.resolveAllowFrom({ cfg, accountId: ctx.AccountId ?? undefined })
|
||||||
|
: undefined;
|
||||||
|
const senderForTrust = ctx.SenderE164 ?? ctx.SenderId ?? ctx.From ?? ctx.To;
|
||||||
|
const isTrustedSender = isSenderTrustedForDeliveryContext({
|
||||||
|
sender: senderForTrust,
|
||||||
|
allowFrom: inboundAllowFrom,
|
||||||
|
});
|
||||||
|
|
||||||
// Track the originating channel/to for announce routing (subagent announce-back).
|
// Track the originating channel/to for announce routing (subagent announce-back).
|
||||||
const lastChannelRaw = (ctx.OriginatingChannel as string | undefined) || baseEntry?.lastChannel;
|
// Only update from new inbound message if sender is trusted.
|
||||||
const lastToRaw = (ctx.OriginatingTo as string | undefined) || ctx.To || baseEntry?.lastTo;
|
const lastChannelRaw = isTrustedSender
|
||||||
const lastAccountIdRaw = (ctx.AccountId as string | undefined) || baseEntry?.lastAccountId;
|
? (ctx.OriginatingChannel as string | undefined) || baseEntry?.lastChannel
|
||||||
const lastThreadIdRaw =
|
: baseEntry?.lastChannel;
|
||||||
(ctx.MessageThreadId as string | number | undefined) || baseEntry?.lastThreadId;
|
const lastToRaw = isTrustedSender
|
||||||
|
? (ctx.OriginatingTo as string | undefined) || ctx.To || baseEntry?.lastTo
|
||||||
|
: baseEntry?.lastTo;
|
||||||
|
const lastAccountIdRaw = isTrustedSender
|
||||||
|
? (ctx.AccountId as string | undefined) || baseEntry?.lastAccountId
|
||||||
|
: baseEntry?.lastAccountId;
|
||||||
|
const lastThreadIdRaw = isTrustedSender
|
||||||
|
? (ctx.MessageThreadId as string | number | undefined) || baseEntry?.lastThreadId
|
||||||
|
: baseEntry?.lastThreadId;
|
||||||
const deliveryFields = normalizeSessionDeliveryFields({
|
const deliveryFields = normalizeSessionDeliveryFields({
|
||||||
deliveryContext: {
|
deliveryContext: {
|
||||||
channel: lastChannelRaw,
|
channel: lastChannelRaw,
|
||||||
|
|||||||
@ -11,7 +11,7 @@ export const whatsappOutbound: ChannelOutboundAdapter = {
|
|||||||
chunkerMode: "text",
|
chunkerMode: "text",
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
pollMaxOptions: 12,
|
pollMaxOptions: 12,
|
||||||
resolveTarget: ({ to, allowFrom, mode }) => {
|
resolveTarget: ({ to, allowFrom, mode, allowUnlisted }) => {
|
||||||
const trimmed = to?.trim() ?? "";
|
const trimmed = to?.trim() ?? "";
|
||||||
const allowListRaw = (allowFrom ?? []).map((entry) => String(entry).trim()).filter(Boolean);
|
const allowListRaw = (allowFrom ?? []).map((entry) => String(entry).trim()).filter(Boolean);
|
||||||
const hasWildcard = allowListRaw.includes("*");
|
const hasWildcard = allowListRaw.includes("*");
|
||||||
@ -34,6 +34,7 @@ export const whatsappOutbound: ChannelOutboundAdapter = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// Groups are always allowed (no allowlist check needed)
|
||||||
if (isWhatsAppGroupJid(normalizedTo)) {
|
if (isWhatsAppGroupJid(normalizedTo)) {
|
||||||
return { ok: true, to: normalizedTo };
|
return { ok: true, to: normalizedTo };
|
||||||
}
|
}
|
||||||
@ -46,6 +47,28 @@ export const whatsappOutbound: ChannelOutboundAdapter = {
|
|||||||
}
|
}
|
||||||
return { ok: true, to: allowList[0] };
|
return { ok: true, to: allowList[0] };
|
||||||
}
|
}
|
||||||
|
// For explicit mode - validate allowlist unless override is set
|
||||||
|
if (mode === "explicit") {
|
||||||
|
// Wildcard or empty allowlist: allow any target
|
||||||
|
if (hasWildcard || allowList.length === 0) {
|
||||||
|
return { ok: true, to: normalizedTo };
|
||||||
|
}
|
||||||
|
// Target is in allowlist: allow
|
||||||
|
if (allowList.includes(normalizedTo)) {
|
||||||
|
return { ok: true, to: normalizedTo };
|
||||||
|
}
|
||||||
|
// Target not in allowlist: require explicit override
|
||||||
|
if (allowUnlisted) {
|
||||||
|
return { ok: true, to: normalizedTo };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: new Error(
|
||||||
|
`Target ${normalizedTo} not in WhatsApp allowlist (channels.whatsapp.allowFrom). ` +
|
||||||
|
`Add the target to your allowlist, or use --allow-unlisted to override.`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
return { ok: true, to: normalizedTo };
|
return { ok: true, to: normalizedTo };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -98,6 +98,8 @@ export type ChannelOutboundAdapter = {
|
|||||||
allowFrom?: string[];
|
allowFrom?: string[];
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
mode?: ChannelOutboundTargetMode;
|
mode?: ChannelOutboundTargetMode;
|
||||||
|
/** When true, allow explicit targets not in the allowlist (security bypass). */
|
||||||
|
allowUnlisted?: boolean;
|
||||||
}) => { ok: true; to: string } | { ok: false; error: Error };
|
}) => { ok: true; to: string } | { ok: false; error: Error };
|
||||||
sendPayload?: (ctx: ChannelOutboundPayloadContext) => Promise<OutboundDeliveryResult>;
|
sendPayload?: (ctx: ChannelOutboundPayloadContext) => Promise<OutboundDeliveryResult>;
|
||||||
sendText?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
|
sendText?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
|
||||||
|
|||||||
@ -23,7 +23,12 @@ export function registerMessageSendCommand(message: Command, helpers: MessageCli
|
|||||||
.option("--reply-to <id>", "Reply-to message id")
|
.option("--reply-to <id>", "Reply-to message id")
|
||||||
.option("--thread-id <id>", "Thread id (Telegram forum thread)")
|
.option("--thread-id <id>", "Thread id (Telegram forum thread)")
|
||||||
.option("--gif-playback", "Treat video media as GIF playback (WhatsApp only).", false)
|
.option("--gif-playback", "Treat video media as GIF playback (WhatsApp only).", false)
|
||||||
.option("--silent", "Send message silently without notification (Telegram only)", false),
|
.option("--silent", "Send message silently without notification (Telegram only)", false)
|
||||||
|
.option(
|
||||||
|
"--allow-unlisted",
|
||||||
|
"Allow sending to targets not in the channel allowlist (WhatsApp security bypass).",
|
||||||
|
false,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
await helpers.runMessageAction("send", opts);
|
await helpers.runMessageAction("send", opts);
|
||||||
|
|||||||
@ -193,6 +193,12 @@ export type AgentDefaultsConfig = {
|
|||||||
* Default: false (only the final heartbeat payload is delivered).
|
* Default: false (only the final heartbeat payload is delivered).
|
||||||
*/
|
*/
|
||||||
includeReasoning?: boolean;
|
includeReasoning?: boolean;
|
||||||
|
/**
|
||||||
|
* When true, require an explicit target for heartbeat delivery (no implicit routing).
|
||||||
|
* If no explicit target is provided, heartbeat delivery is skipped with reason "require-explicit".
|
||||||
|
* Default: false (backwards compatible; falls back to session-derived or "last" routing).
|
||||||
|
*/
|
||||||
|
requireExplicitTarget?: boolean;
|
||||||
};
|
};
|
||||||
/** Max concurrent agent runs across all conversations. Default: 1 (sequential). */
|
/** Max concurrent agent runs across all conversations. Default: 1 (sequential). */
|
||||||
maxConcurrent?: number;
|
maxConcurrent?: number;
|
||||||
|
|||||||
@ -674,6 +674,7 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
|
|||||||
|
|
||||||
const replyToId = readStringParam(params, "replyTo");
|
const replyToId = readStringParam(params, "replyTo");
|
||||||
const threadId = readStringParam(params, "threadId");
|
const threadId = readStringParam(params, "threadId");
|
||||||
|
const allowUnlisted = readBooleanParam(params, "allowUnlisted") ?? false;
|
||||||
// Slack auto-threading can inject threadTs without explicit params; mirror to that session key.
|
// Slack auto-threading can inject threadTs without explicit params; mirror to that session key.
|
||||||
const slackAutoThreadId =
|
const slackAutoThreadId =
|
||||||
channel === "slack" && !replyToId && !threadId
|
channel === "slack" && !replyToId && !threadId
|
||||||
@ -714,6 +715,7 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
|
|||||||
toolContext: input.toolContext,
|
toolContext: input.toolContext,
|
||||||
deps: input.deps,
|
deps: input.deps,
|
||||||
dryRun,
|
dryRun,
|
||||||
|
allowUnlisted,
|
||||||
mirror:
|
mirror:
|
||||||
outboundRoute && !dryRun
|
outboundRoute && !dryRun
|
||||||
? {
|
? {
|
||||||
|
|||||||
@ -40,6 +40,8 @@ type MessageSendParams = {
|
|||||||
accountId?: string;
|
accountId?: string;
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
bestEffort?: boolean;
|
bestEffort?: boolean;
|
||||||
|
/** When true, allow explicit targets not in the channel allowlist (security bypass). */
|
||||||
|
allowUnlisted?: boolean;
|
||||||
deps?: OutboundSendDeps;
|
deps?: OutboundSendDeps;
|
||||||
cfg?: MoltbotConfig;
|
cfg?: MoltbotConfig;
|
||||||
gateway?: MessageGatewayOptions;
|
gateway?: MessageGatewayOptions;
|
||||||
@ -156,6 +158,7 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
|
|||||||
cfg,
|
cfg,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
mode: "explicit",
|
mode: "explicit",
|
||||||
|
allowUnlisted: params.allowUnlisted,
|
||||||
});
|
});
|
||||||
if (!resolvedTarget.ok) throw resolvedTarget.error;
|
if (!resolvedTarget.ok) throw resolvedTarget.error;
|
||||||
|
|
||||||
@ -203,6 +206,7 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
|
|||||||
channel,
|
channel,
|
||||||
sessionKey: params.mirror?.sessionKey,
|
sessionKey: params.mirror?.sessionKey,
|
||||||
idempotencyKey: params.idempotencyKey ?? randomIdempotencyKey(),
|
idempotencyKey: params.idempotencyKey ?? randomIdempotencyKey(),
|
||||||
|
allowUnlisted: params.allowUnlisted,
|
||||||
},
|
},
|
||||||
timeoutMs: gateway.timeoutMs,
|
timeoutMs: gateway.timeoutMs,
|
||||||
clientName: gateway.clientName,
|
clientName: gateway.clientName,
|
||||||
|
|||||||
@ -26,6 +26,8 @@ export type OutboundSendContext = {
|
|||||||
toolContext?: ChannelThreadingToolContext;
|
toolContext?: ChannelThreadingToolContext;
|
||||||
deps?: OutboundSendDeps;
|
deps?: OutboundSendDeps;
|
||||||
dryRun: boolean;
|
dryRun: boolean;
|
||||||
|
/** When true, allow explicit targets not in the channel allowlist (security bypass). */
|
||||||
|
allowUnlisted?: boolean;
|
||||||
mirror?: {
|
mirror?: {
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
@ -125,6 +127,7 @@ export async function executeSendAction(params: {
|
|||||||
gifPlayback: params.gifPlayback,
|
gifPlayback: params.gifPlayback,
|
||||||
dryRun: params.ctx.dryRun,
|
dryRun: params.ctx.dryRun,
|
||||||
bestEffort: params.bestEffort ?? undefined,
|
bestEffort: params.bestEffort ?? undefined,
|
||||||
|
allowUnlisted: params.ctx.allowUnlisted,
|
||||||
deps: params.ctx.deps,
|
deps: params.ctx.deps,
|
||||||
gateway: params.ctx.gateway,
|
gateway: params.ctx.gateway,
|
||||||
mirror: params.ctx.mirror,
|
mirror: params.ctx.mirror,
|
||||||
|
|||||||
@ -5,7 +5,11 @@ import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
|||||||
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
|
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||||
import { telegramPlugin } from "../../../extensions/telegram/src/channel.js";
|
import { telegramPlugin } from "../../../extensions/telegram/src/channel.js";
|
||||||
import { whatsappPlugin } from "../../../extensions/whatsapp/src/channel.js";
|
import { whatsappPlugin } from "../../../extensions/whatsapp/src/channel.js";
|
||||||
import { resolveOutboundTarget, resolveSessionDeliveryTarget } from "./targets.js";
|
import {
|
||||||
|
resolveHeartbeatDeliveryTarget,
|
||||||
|
resolveOutboundTarget,
|
||||||
|
resolveSessionDeliveryTarget,
|
||||||
|
} from "./targets.js";
|
||||||
|
|
||||||
describe("resolveOutboundTarget", () => {
|
describe("resolveOutboundTarget", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -105,6 +109,178 @@ describe("resolveOutboundTarget", () => {
|
|||||||
expect(res.error.message).toContain("WebChat");
|
expect(res.error.message).toContain("WebChat");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("whatsapp explicit mode allowlist validation", () => {
|
||||||
|
it("rejects explicit target not in allowlist", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
channels: { whatsapp: { allowFrom: ["+1555000001", "+1555000002"] } },
|
||||||
|
};
|
||||||
|
const res = resolveOutboundTarget({
|
||||||
|
channel: "whatsapp",
|
||||||
|
to: "+1555999999",
|
||||||
|
cfg,
|
||||||
|
mode: "explicit",
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
expect(res.error.message).toContain("not in WhatsApp allowlist");
|
||||||
|
expect(res.error.message).toContain("--allow-unlisted");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows explicit target when in allowlist", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
channels: { whatsapp: { allowFrom: ["+1555000001", "+1555000002"] } },
|
||||||
|
};
|
||||||
|
const res = resolveOutboundTarget({
|
||||||
|
channel: "whatsapp",
|
||||||
|
to: "+1555000001",
|
||||||
|
cfg,
|
||||||
|
mode: "explicit",
|
||||||
|
});
|
||||||
|
expect(res).toEqual({ ok: true, to: "+1555000001" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows explicit target with allowUnlisted override", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
channels: { whatsapp: { allowFrom: ["+1555000001"] } },
|
||||||
|
};
|
||||||
|
const res = resolveOutboundTarget({
|
||||||
|
channel: "whatsapp",
|
||||||
|
to: "+1555999999",
|
||||||
|
cfg,
|
||||||
|
mode: "explicit",
|
||||||
|
allowUnlisted: true,
|
||||||
|
});
|
||||||
|
expect(res).toEqual({ ok: true, to: "+1555999999" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows explicit target when allowlist has wildcard", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
channels: { whatsapp: { allowFrom: ["*", "+1555000001"] } },
|
||||||
|
};
|
||||||
|
const res = resolveOutboundTarget({
|
||||||
|
channel: "whatsapp",
|
||||||
|
to: "+1555999999",
|
||||||
|
cfg,
|
||||||
|
mode: "explicit",
|
||||||
|
});
|
||||||
|
expect(res).toEqual({ ok: true, to: "+1555999999" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows explicit target when allowlist is empty", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
channels: { whatsapp: { allowFrom: [] } },
|
||||||
|
};
|
||||||
|
const res = resolveOutboundTarget({
|
||||||
|
channel: "whatsapp",
|
||||||
|
to: "+1555999999",
|
||||||
|
cfg,
|
||||||
|
mode: "explicit",
|
||||||
|
});
|
||||||
|
expect(res).toEqual({ ok: true, to: "+1555999999" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows group targets regardless of allowlist", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
channels: { whatsapp: { allowFrom: ["+1555000001"] } },
|
||||||
|
};
|
||||||
|
const res = resolveOutboundTarget({
|
||||||
|
channel: "whatsapp",
|
||||||
|
to: "120363401234567890@g.us",
|
||||||
|
cfg,
|
||||||
|
mode: "explicit",
|
||||||
|
});
|
||||||
|
expect(res).toEqual({ ok: true, to: "120363401234567890@g.us" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("implicit mode still allows targets not in allowlist (fallback to first)", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
channels: { whatsapp: { allowFrom: ["+1555000001", "+1555000002"] } },
|
||||||
|
};
|
||||||
|
const res = resolveOutboundTarget({
|
||||||
|
channel: "whatsapp",
|
||||||
|
to: "+1555999999",
|
||||||
|
cfg,
|
||||||
|
mode: "implicit",
|
||||||
|
});
|
||||||
|
// In implicit mode, unlisted targets fall back to the first allowFrom entry
|
||||||
|
expect(res).toEqual({ ok: true, to: "+1555000001" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveHeartbeatDeliveryTarget", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePluginRegistry(
|
||||||
|
createTestRegistry([
|
||||||
|
{ pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" },
|
||||||
|
{ pluginId: "telegram", plugin: telegramPlugin, source: "test" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns none with reason require-explicit when requireExplicitTarget is true and no to is set", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
heartbeat: {
|
||||||
|
target: "whatsapp",
|
||||||
|
requireExplicitTarget: true,
|
||||||
|
// no `to` set
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
channels: { whatsapp: { allowFrom: ["+1555000001"] } },
|
||||||
|
};
|
||||||
|
const result = resolveHeartbeatDeliveryTarget({
|
||||||
|
cfg,
|
||||||
|
entry: { sessionId: "test", updatedAt: 1, lastChannel: "whatsapp", lastTo: "+1555000001" },
|
||||||
|
});
|
||||||
|
expect(result.channel).toBe("none");
|
||||||
|
expect(result.reason).toBe("require-explicit");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows heartbeat delivery when requireExplicitTarget is true and to is set", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
heartbeat: {
|
||||||
|
target: "whatsapp",
|
||||||
|
requireExplicitTarget: true,
|
||||||
|
to: "+1555000001",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
channels: { whatsapp: { allowFrom: ["+1555000001"] } },
|
||||||
|
};
|
||||||
|
const result = resolveHeartbeatDeliveryTarget({
|
||||||
|
cfg,
|
||||||
|
entry: { sessionId: "test", updatedAt: 1, lastChannel: "whatsapp", lastTo: "+1555000002" },
|
||||||
|
});
|
||||||
|
expect(result.channel).toBe("whatsapp");
|
||||||
|
expect(result.to).toBe("+1555000001");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows implicit routing when requireExplicitTarget is false (default)", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
heartbeat: {
|
||||||
|
target: "last",
|
||||||
|
// requireExplicitTarget defaults to false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
channels: { whatsapp: { allowFrom: ["+1555000001"] } },
|
||||||
|
};
|
||||||
|
const result = resolveHeartbeatDeliveryTarget({
|
||||||
|
cfg,
|
||||||
|
entry: { sessionId: "test", updatedAt: 1, lastChannel: "whatsapp", lastTo: "+1555000001" },
|
||||||
|
});
|
||||||
|
expect(result.channel).toBe("whatsapp");
|
||||||
|
expect(result.to).toBe("+1555000001");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveSessionDeliveryTarget", () => {
|
describe("resolveSessionDeliveryTarget", () => {
|
||||||
|
|||||||
@ -122,6 +122,8 @@ export function resolveOutboundTarget(params: {
|
|||||||
cfg?: MoltbotConfig;
|
cfg?: MoltbotConfig;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
mode?: ChannelOutboundTargetMode;
|
mode?: ChannelOutboundTargetMode;
|
||||||
|
/** When true, allow explicit targets not in the allowlist (security bypass). */
|
||||||
|
allowUnlisted?: boolean;
|
||||||
}): OutboundTargetResolution {
|
}): OutboundTargetResolution {
|
||||||
if (params.channel === INTERNAL_MESSAGE_CHANNEL) {
|
if (params.channel === INTERNAL_MESSAGE_CHANNEL) {
|
||||||
return {
|
return {
|
||||||
@ -157,6 +159,7 @@ export function resolveOutboundTarget(params: {
|
|||||||
allowFrom,
|
allowFrom,
|
||||||
accountId: params.accountId ?? undefined,
|
accountId: params.accountId ?? undefined,
|
||||||
mode: params.mode ?? "explicit",
|
mode: params.mode ?? "explicit",
|
||||||
|
allowUnlisted: params.allowUnlisted,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -179,6 +182,8 @@ export function resolveHeartbeatDeliveryTarget(params: {
|
|||||||
const { cfg, entry } = params;
|
const { cfg, entry } = params;
|
||||||
const heartbeat = params.heartbeat ?? cfg.agents?.defaults?.heartbeat;
|
const heartbeat = params.heartbeat ?? cfg.agents?.defaults?.heartbeat;
|
||||||
const rawTarget = heartbeat?.target;
|
const rawTarget = heartbeat?.target;
|
||||||
|
const requireExplicitTarget = heartbeat?.requireExplicitTarget ?? false;
|
||||||
|
const explicitTo = heartbeat?.to?.trim();
|
||||||
let target: HeartbeatTarget = "last";
|
let target: HeartbeatTarget = "last";
|
||||||
if (rawTarget === "none" || rawTarget === "last") {
|
if (rawTarget === "none" || rawTarget === "last") {
|
||||||
target = rawTarget;
|
target = rawTarget;
|
||||||
@ -198,10 +203,22 @@ export function resolveHeartbeatDeliveryTarget(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When requireExplicitTarget is enabled, only allow delivery if an explicit target is set
|
||||||
|
if (requireExplicitTarget && !explicitTo) {
|
||||||
|
const base = resolveSessionDeliveryTarget({ entry });
|
||||||
|
return {
|
||||||
|
channel: "none",
|
||||||
|
reason: "require-explicit",
|
||||||
|
accountId: undefined,
|
||||||
|
lastChannel: base.lastChannel,
|
||||||
|
lastAccountId: base.lastAccountId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const resolvedTarget = resolveSessionDeliveryTarget({
|
const resolvedTarget = resolveSessionDeliveryTarget({
|
||||||
entry,
|
entry,
|
||||||
requestedChannel: target === "last" ? "last" : target,
|
requestedChannel: target === "last" ? "last" : target,
|
||||||
explicitTo: heartbeat?.to,
|
explicitTo,
|
||||||
mode: "heartbeat",
|
mode: "heartbeat",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,103 +1,92 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { isSenderTrustedForDeliveryContext } from "./delivery-context.js";
|
||||||
|
|
||||||
import {
|
describe("isSenderTrustedForDeliveryContext", () => {
|
||||||
deliveryContextKey,
|
it("returns true when trustAll is set", () => {
|
||||||
deliveryContextFromSession,
|
const result = isSenderTrustedForDeliveryContext({
|
||||||
mergeDeliveryContext,
|
sender: "+1999999999",
|
||||||
normalizeDeliveryContext,
|
allowFrom: ["+1555000001"],
|
||||||
normalizeSessionDeliveryFields,
|
trustAll: true,
|
||||||
} from "./delivery-context.js";
|
});
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
describe("delivery context helpers", () => {
|
it("returns true when allowFrom is empty (open policy)", () => {
|
||||||
it("normalizes channel/to/accountId and drops empty contexts", () => {
|
const result = isSenderTrustedForDeliveryContext({
|
||||||
|
sender: "+1999999999",
|
||||||
|
allowFrom: [],
|
||||||
|
});
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when allowFrom is undefined (open policy)", () => {
|
||||||
|
const result = isSenderTrustedForDeliveryContext({
|
||||||
|
sender: "+1999999999",
|
||||||
|
});
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when allowFrom has wildcard", () => {
|
||||||
|
const result = isSenderTrustedForDeliveryContext({
|
||||||
|
sender: "+1999999999",
|
||||||
|
allowFrom: ["*", "+1555000001"],
|
||||||
|
});
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when sender is in allowFrom", () => {
|
||||||
|
const result = isSenderTrustedForDeliveryContext({
|
||||||
|
sender: "+1555000001",
|
||||||
|
allowFrom: ["+1555000001", "+1555000002"],
|
||||||
|
});
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when sender is not in allowFrom", () => {
|
||||||
|
const result = isSenderTrustedForDeliveryContext({
|
||||||
|
sender: "+1999999999",
|
||||||
|
allowFrom: ["+1555000001", "+1555000002"],
|
||||||
|
});
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when sender is null/undefined", () => {
|
||||||
expect(
|
expect(
|
||||||
normalizeDeliveryContext({
|
isSenderTrustedForDeliveryContext({
|
||||||
channel: " whatsapp ",
|
sender: null,
|
||||||
to: " +1555 ",
|
allowFrom: ["+1555000001"],
|
||||||
accountId: " acct-1 ",
|
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toBe(false);
|
||||||
channel: "whatsapp",
|
|
||||||
to: "+1555",
|
|
||||||
accountId: "acct-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(normalizeDeliveryContext({ channel: " " })).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("merges primary values over fallback", () => {
|
|
||||||
const merged = mergeDeliveryContext(
|
|
||||||
{ channel: "whatsapp", to: "channel:abc" },
|
|
||||||
{ channel: "slack", to: "channel:def", accountId: "acct" },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(merged).toEqual({
|
|
||||||
channel: "whatsapp",
|
|
||||||
to: "channel:abc",
|
|
||||||
accountId: "acct",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("builds stable keys only when channel and to are present", () => {
|
|
||||||
expect(deliveryContextKey({ channel: "whatsapp", to: "+1555" })).toBe("whatsapp|+1555||");
|
|
||||||
expect(deliveryContextKey({ channel: "whatsapp" })).toBeUndefined();
|
|
||||||
expect(deliveryContextKey({ channel: "whatsapp", to: "+1555", accountId: "acct-1" })).toBe(
|
|
||||||
"whatsapp|+1555|acct-1|",
|
|
||||||
);
|
|
||||||
expect(deliveryContextKey({ channel: "slack", to: "channel:C1", threadId: "123.456" })).toBe(
|
|
||||||
"slack|channel:C1||123.456",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("derives delivery context from a session entry", () => {
|
|
||||||
expect(
|
|
||||||
deliveryContextFromSession({
|
|
||||||
channel: "webchat",
|
|
||||||
lastChannel: " whatsapp ",
|
|
||||||
lastTo: " +1777 ",
|
|
||||||
lastAccountId: " acct-9 ",
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
channel: "whatsapp",
|
|
||||||
to: "+1777",
|
|
||||||
accountId: "acct-9",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
deliveryContextFromSession({
|
isSenderTrustedForDeliveryContext({
|
||||||
channel: "telegram",
|
sender: undefined,
|
||||||
lastTo: " 123 ",
|
allowFrom: ["+1555000001"],
|
||||||
lastThreadId: " 999 ",
|
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toBe(false);
|
||||||
channel: "telegram",
|
|
||||||
to: "123",
|
|
||||||
accountId: undefined,
|
|
||||||
threadId: "999",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes delivery fields and mirrors them on session entries", () => {
|
it("returns false when sender is empty string", () => {
|
||||||
const normalized = normalizeSessionDeliveryFields({
|
const result = isSenderTrustedForDeliveryContext({
|
||||||
deliveryContext: {
|
sender: " ",
|
||||||
channel: " Slack ",
|
allowFrom: ["+1555000001"],
|
||||||
to: " channel:1 ",
|
});
|
||||||
accountId: " acct-2 ",
|
expect(result).toBe(false);
|
||||||
threadId: " 444 ",
|
|
||||||
},
|
|
||||||
lastChannel: " whatsapp ",
|
|
||||||
lastTo: " +1555 ",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(normalized.deliveryContext).toEqual({
|
it("handles number entries in allowFrom", () => {
|
||||||
channel: "whatsapp",
|
const result = isSenderTrustedForDeliveryContext({
|
||||||
to: "+1555",
|
sender: "123456789",
|
||||||
accountId: "acct-2",
|
allowFrom: [123456789, "+1555000001"],
|
||||||
threadId: "444",
|
|
||||||
});
|
});
|
||||||
expect(normalized.lastChannel).toBe("whatsapp");
|
expect(result).toBe(true);
|
||||||
expect(normalized.lastTo).toBe("+1555");
|
});
|
||||||
expect(normalized.lastAccountId).toBe("acct-2");
|
|
||||||
expect(normalized.lastThreadId).toBe("444");
|
it("trims sender and allowFrom entries for matching", () => {
|
||||||
|
const result = isSenderTrustedForDeliveryContext({
|
||||||
|
sender: " +1555000001 ",
|
||||||
|
allowFrom: [" +1555000001 "],
|
||||||
|
});
|
||||||
|
expect(result).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -8,6 +8,44 @@ export type DeliveryContext = {
|
|||||||
threadId?: string | number;
|
threadId?: string | number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DeliveryContextTrustParams = {
|
||||||
|
/** Sender identifier (e.g., E.164 phone number, user id). */
|
||||||
|
sender?: string | null;
|
||||||
|
/** Allowlist entries for the channel. */
|
||||||
|
allowFrom?: Array<string | number>;
|
||||||
|
/** When true, trust any sender (no allowlist check). */
|
||||||
|
trustAll?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates whether a sender is trusted for updating the delivery context.
|
||||||
|
* Only messages from allowlisted senders (or when trustAll/wildcard is set) should
|
||||||
|
* update the delivery context to prevent routing replies to unknown recipients.
|
||||||
|
*
|
||||||
|
* @returns true if the sender is trusted, false otherwise
|
||||||
|
*/
|
||||||
|
export function isSenderTrustedForDeliveryContext(params: DeliveryContextTrustParams): boolean {
|
||||||
|
const { sender, allowFrom, trustAll } = params;
|
||||||
|
|
||||||
|
// If trustAll is explicitly set, allow
|
||||||
|
if (trustAll) return true;
|
||||||
|
|
||||||
|
// If no allowFrom list, allow (open policy)
|
||||||
|
if (!allowFrom || allowFrom.length === 0) return true;
|
||||||
|
|
||||||
|
// Check for wildcard in allowlist
|
||||||
|
const hasWildcard = allowFrom.some((entry) => String(entry).trim() === "*");
|
||||||
|
if (hasWildcard) return true;
|
||||||
|
|
||||||
|
// No sender to check - don't trust
|
||||||
|
const senderNormalized = sender?.trim();
|
||||||
|
if (!senderNormalized) return false;
|
||||||
|
|
||||||
|
// Check if sender is in allowlist
|
||||||
|
const allowList = allowFrom.map((entry) => String(entry).trim()).filter(Boolean);
|
||||||
|
return allowList.includes(senderNormalized);
|
||||||
|
}
|
||||||
|
|
||||||
export type DeliveryContextSessionSource = {
|
export type DeliveryContextSessionSource = {
|
||||||
channel?: string;
|
channel?: string;
|
||||||
lastChannel?: string;
|
lastChannel?: string;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user