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:
Diogo Ortega 2026-01-25 14:01:45 +00:00
parent 735aea9efa
commit fc379a1332
13 changed files with 416 additions and 99 deletions

View File

@ -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 };
} }

View File

@ -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,

View File

@ -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 };
} }

View File

@ -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>;

View File

@ -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);

View File

@ -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;

View File

@ -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
? { ? {

View File

@ -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,

View File

@ -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,

View File

@ -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", () => {

View File

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

View File

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

View File

@ -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;