From 513f3556e7296cd2877d5159a41d96351e385b93 Mon Sep 17 00:00:00 2001 From: Joel Cooper Date: Thu, 29 Jan 2026 14:34:19 -0700 Subject: [PATCH] fix: enforce allowlist for explicit sends across all channels Explicit-mode sends (agent tool calls, gateway send command) bypassed the allowFrom allowlist on every channel adapter. An agent hallucination or prompt injection could send messages to arbitrary recipients despite dmPolicy: "allowlist" being configured. Fix by: - Adding allowlist enforcement to the default fallback in targets.ts, covering all channels without a custom resolveTarget (Discord, Slack, Matrix, MS Teams, etc.) - Fixing WhatsApp (core + extension), Twitch, and Google Chat adapters to reject explicit sends to non-allowlisted targets - Enforcing allowlist on WhatsApp group JIDs (previously unguarded) Implicit and heartbeat modes still fall back to allowList[0] as before. AI-assisted (Claude). Tested locally. Co-Authored-By: Claude Opus 4.5 --- extensions/googlechat/src/channel.ts | 12 +++++++++- extensions/twitch/src/outbound.ts | 20 ++++++----------- extensions/whatsapp/src/channel.ts | 20 +++++++++++------ src/channels/plugins/outbound/whatsapp.ts | 27 +++++++++++++++++------ src/infra/outbound/targets.ts | 10 +++++++++ 5 files changed, 61 insertions(+), 28 deletions(-) diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index eaa922767..f2657f3eb 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -398,7 +398,17 @@ export const googlechatPlugin: ChannelPlugin = { ), }; } - return { ok: true, to: normalized }; + const hasWildcard = allowListRaw.includes("*"); + if (hasWildcard || allowList.length === 0 || allowList.includes(normalized)) { + return { ok: true, to: normalized }; + } + if (mode === "implicit" || mode === "heartbeat") { + return { ok: true, to: allowList[0] }; + } + return { + ok: false, + error: new Error(`Google Chat target ${trimmed} is not in the allowFrom list.`), + }; } if (allowList.length > 0) { diff --git a/extensions/twitch/src/outbound.ts b/extensions/twitch/src/outbound.ts index 1fa172820..24965f3b8 100644 --- a/extensions/twitch/src/outbound.ts +++ b/extensions/twitch/src/outbound.ts @@ -51,25 +51,19 @@ export const twitchOutbound: ChannelOutboundAdapter = { .map((entry: string) => normalizeTwitchChannel(entry)) .filter((entry): entry is string => entry.length > 0); - // If target is provided, normalize and validate it if (trimmed) { const normalizedTo = normalizeTwitchChannel(trimmed); - - // For implicit/heartbeat modes with allowList, check against allowlist + if (hasWildcard || allowList.length === 0 || allowList.includes(normalizedTo)) { + return { ok: true, to: normalizedTo }; + } if (mode === "implicit" || mode === "heartbeat") { - if (hasWildcard || allowList.length === 0) { - return { ok: true, to: normalizedTo }; - } - if (allowList.includes(normalizedTo)) { - return { ok: true, to: normalizedTo }; - } - // Fallback to first allowFrom entry // biome-ignore lint/style/noNonNullAssertion: length > 0 check ensures element exists return { ok: true, to: allowList[0]! }; } - - // For explicit mode, accept any valid channel name - return { ok: true, to: normalizedTo }; + return { + ok: false, + error: new Error(`Twitch target ${trimmed} is not in the allowFrom list.`), + }; } // No target provided, use allowFrom fallback diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index 9d37fcf2a..1c0488eea 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -303,18 +303,24 @@ export const whatsappPlugin: ChannelPlugin = { }; } if (isWhatsAppGroupJid(normalizedTo)) { + if (hasWildcard || allowList.length === 0 || allowList.includes(normalizedTo)) { + return { ok: true, to: normalizedTo }; + } + return { + ok: false, + error: new Error(`WhatsApp group ${trimmed} is not in the allowFrom list.`), + }; + } + if (hasWildcard || allowList.length === 0 || allowList.includes(normalizedTo)) { return { ok: true, to: normalizedTo }; } if (mode === "implicit" || mode === "heartbeat") { - if (hasWildcard || allowList.length === 0) { - return { ok: true, to: normalizedTo }; - } - if (allowList.includes(normalizedTo)) { - return { ok: true, to: normalizedTo }; - } return { ok: true, to: allowList[0] }; } - return { ok: true, to: normalizedTo }; + return { + ok: false, + error: new Error(`WhatsApp target ${trimmed} is not in the allowFrom list.`), + }; } if (allowList.length > 0) { diff --git a/src/channels/plugins/outbound/whatsapp.ts b/src/channels/plugins/outbound/whatsapp.ts index 303a015da..92c3acd4e 100644 --- a/src/channels/plugins/outbound/whatsapp.ts +++ b/src/channels/plugins/outbound/whatsapp.ts @@ -35,18 +35,31 @@ export const whatsappOutbound: ChannelOutboundAdapter = { }; } if (isWhatsAppGroupJid(normalizedTo)) { + // Groups also restricted to allowlist when configured + if (hasWildcard || allowList.length === 0 || allowList.includes(normalizedTo)) { + return { ok: true, to: normalizedTo }; + } + return { + ok: false, + error: new Error(`WhatsApp group ${trimmed} is not in the allowFrom list.`), + }; + } + if (hasWildcard || allowList.length === 0) { return { ok: true, to: normalizedTo }; } + if (allowList.includes(normalizedTo)) { + return { ok: true, to: normalizedTo }; + } + // Target not in allowlist — fallback for implicit, reject for explicit if (mode === "implicit" || mode === "heartbeat") { - if (hasWildcard || allowList.length === 0) { - return { ok: true, to: normalizedTo }; - } - if (allowList.includes(normalizedTo)) { - return { ok: true, to: normalizedTo }; - } return { ok: true, to: allowList[0] }; } - return { ok: true, to: normalizedTo }; + return { + ok: false, + error: new Error( + `WhatsApp target ${trimmed} is not in the allowFrom list. Allowed: ${allowListRaw.join(", ")}`, + ), + }; } if (allowList.length > 0) { diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index 8b557c0a6..72a9813b4 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -162,6 +162,16 @@ export function resolveOutboundTarget(params: { const trimmed = params.to?.trim(); if (trimmed) { + const mode = params.mode ?? "explicit"; + if (allowFrom && allowFrom.length > 0 && !allowFrom.includes("*")) { + const list = allowFrom.map((e) => String(e).trim()).filter(Boolean); + if (list.includes(trimmed)) return { ok: true, to: trimmed }; + if (mode === "implicit" || mode === "heartbeat") { + const fallback = list[0]; + if (fallback) return { ok: true, to: fallback }; + } + return { ok: false, error: new Error(`Target ${trimmed} is not in the allowFrom list.`) }; + } return { ok: true, to: trimmed }; } const hint = plugin.messaging?.targetResolver?.hint;