diff --git a/.storkit/bot.toml.whatsapp-meta.example b/.storkit/bot.toml.whatsapp-meta.example index 33439ec..5fac5d8 100644 --- a/.storkit/bot.toml.whatsapp-meta.example +++ b/.storkit/bot.toml.whatsapp-meta.example @@ -26,3 +26,8 @@ whatsapp_verify_token = "my-secret-verify-token" # Maximum conversation turns to remember per user (default: 20). # history_size = 20 + +# Optional: restrict which phone numbers can interact with the bot. +# When set, only listed numbers are processed; all others are silently ignored. +# When absent or empty, all numbers are allowed (open by default). +# whatsapp_allowed_phones = ["+15551234567", "+15559876543"] diff --git a/.storkit/bot.toml.whatsapp-twilio.example b/.storkit/bot.toml.whatsapp-twilio.example index 30ed55b..138f05d 100644 --- a/.storkit/bot.toml.whatsapp-twilio.example +++ b/.storkit/bot.toml.whatsapp-twilio.example @@ -22,3 +22,8 @@ twilio_whatsapp_number = "+14155238886" # Maximum conversation turns to remember per user (default: 20). # history_size = 20 + +# Optional: restrict which phone numbers can interact with the bot. +# When set, only listed numbers are processed; all others are silently ignored. +# When absent or empty, all numbers are allowed (open by default). +# whatsapp_allowed_phones = ["+15551234567", "+15559876543"] diff --git a/server/src/chat/transport/matrix/config.rs b/server/src/chat/transport/matrix/config.rs index 0575c98..751c20b 100644 --- a/server/src/chat/transport/matrix/config.rs +++ b/server/src/chat/transport/matrix/config.rs @@ -114,6 +114,14 @@ pub struct BotConfig { #[serde(default)] pub twilio_whatsapp_number: Option, + /// Phone numbers allowed to interact with the bot when using WhatsApp. + /// When non-empty, only listed numbers can send commands; all others are + /// silently ignored. When empty or absent, all numbers are allowed + /// (backwards compatible — open by default, unlike Matrix which is + /// fail-closed). + #[serde(default)] + pub whatsapp_allowed_phones: Vec, + // ── Slack Bot API fields ───────────────────────────────────────── // These are only required when `transport = "slack"`. @@ -1010,4 +1018,40 @@ slack_signing_secret = "secret123" .unwrap(); assert!(BotConfig::load(tmp.path()).is_none()); } + + #[test] + fn whatsapp_allowed_phones_defaults_to_empty_when_absent() { + let config: BotConfig = toml::from_str( + r#" +enabled = true +transport = "whatsapp" +whatsapp_provider = "meta" +whatsapp_phone_number_id = "123" +whatsapp_access_token = "tok" +whatsapp_verify_token = "ver" +"#, + ) + .unwrap(); + assert!(config.whatsapp_allowed_phones.is_empty()); + } + + #[test] + fn whatsapp_allowed_phones_deserializes_list() { + let config: BotConfig = toml::from_str( + r#" +enabled = true +transport = "whatsapp" +whatsapp_provider = "meta" +whatsapp_phone_number_id = "123" +whatsapp_access_token = "tok" +whatsapp_verify_token = "ver" +whatsapp_allowed_phones = ["+15551234567", "+15559876543"] +"#, + ) + .unwrap(); + assert_eq!( + config.whatsapp_allowed_phones, + vec!["+15551234567", "+15559876543"] + ); + } } diff --git a/server/src/chat/transport/whatsapp.rs b/server/src/chat/transport/whatsapp.rs index 8d8e148..11edda1 100644 --- a/server/src/chat/transport/whatsapp.rs +++ b/server/src/chat/transport/whatsapp.rs @@ -881,6 +881,9 @@ pub struct WhatsAppWebhookContext { pub history_size: usize, /// Tracks the 24-hour messaging window per user phone number. pub window_tracker: Arc, + /// Phone numbers allowed to send messages to the bot. + /// When empty, all numbers are allowed (backwards compatible). + pub allowed_phones: Vec, } /// GET /webhook/whatsapp — webhook verification. @@ -977,6 +980,14 @@ pub async fn webhook_receive( async fn handle_incoming_message(ctx: &WhatsAppWebhookContext, sender: &str, message: &str) { use crate::chat::transport::matrix::commands::{CommandDispatch, try_handle_command}; + // Allowlist check: when configured, silently ignore unauthorized senders. + if !ctx.allowed_phones.is_empty() + && !ctx.allowed_phones.iter().any(|p| p == sender) + { + slog!("[whatsapp] Ignoring message from unauthorized sender: {sender}"); + return; + } + // Record this inbound message to keep the 24-hour window open. ctx.window_tracker.record_message(sender); @@ -1820,6 +1831,106 @@ mod tests { let _msgs = extract_twilio_text_messages(body); } + // ── Allowlist tests ─────────────────────────────────────────────────── + + /// Build a minimal WhatsAppWebhookContext for allowlist tests. + fn make_ctx_with_allowlist( + allowed_phones: Vec, + ) -> Arc { + use crate::agents::AgentPool; + use crate::io::watcher::WatcherEvent; + + struct NullTransport; + + #[async_trait::async_trait] + impl crate::chat::ChatTransport for NullTransport { + async fn send_message( + &self, + _room: &str, + _plain: &str, + _html: &str, + ) -> Result { + Ok(String::new()) + } + async fn edit_message( + &self, + _room: &str, + _id: &str, + _plain: &str, + _html: &str, + ) -> Result<(), String> { + Ok(()) + } + async fn send_typing(&self, _room: &str, _typing: bool) -> Result<(), String> { + Ok(()) + } + } + + let tmp = tempfile::tempdir().unwrap(); + let (tx, _rx) = tokio::sync::broadcast::channel::(16); + let agents = Arc::new(AgentPool::new(3999, tx)); + let tracker = Arc::new(MessagingWindowTracker::new()); + Arc::new(WhatsAppWebhookContext { + verify_token: "tok".to_string(), + provider: "meta".to_string(), + transport: Arc::new(NullTransport), + project_root: tmp.path().to_path_buf(), + agents, + bot_name: "Bot".to_string(), + bot_user_id: "whatsapp-bot".to_string(), + ambient_rooms: Arc::new(std::sync::Mutex::new(Default::default())), + history: Arc::new(tokio::sync::Mutex::new(Default::default())), + history_size: 20, + window_tracker: tracker, + allowed_phones, + }) + } + + #[tokio::test] + async fn allowlist_blocks_unauthorized_sender() { + let allowed = vec!["+15551111111".to_string()]; + let ctx = make_ctx_with_allowlist(allowed); + let unauthorized = "+15559999999"; + + handle_incoming_message(&ctx, unauthorized, "hello").await; + + // window_tracker is only updated AFTER the allowlist check, so an + // unauthorized sender must leave the tracker untouched. + assert!( + !ctx.window_tracker.is_within_window(unauthorized), + "unauthorized sender should not have updated the window tracker" + ); + } + + #[tokio::test] + async fn allowlist_empty_allows_all_senders() { + // Empty allowlist = open (backwards compatible). + let ctx = make_ctx_with_allowlist(vec![]); + let sender = "+15551234567"; + + handle_incoming_message(&ctx, sender, "hello").await; + + // window_tracker.record_message is called right after the allowlist + // check passes, so the sender should be recorded. + assert!( + ctx.window_tracker.is_within_window(sender), + "sender should be recorded when allowlist is empty" + ); + } + + #[tokio::test] + async fn allowlist_allows_listed_sender() { + let sender = "+15551111111"; + let ctx = make_ctx_with_allowlist(vec![sender.to_string()]); + + handle_incoming_message(&ctx, sender, "hello").await; + + assert!( + ctx.window_tracker.is_within_window(sender), + "listed sender should be recorded in the window tracker" + ); + } + #[test] fn load_whatsapp_history_returns_empty_when_file_missing() { let tmp = tempfile::tempdir().unwrap(); diff --git a/server/src/main.rs b/server/src/main.rs index 1621781..b8f696c 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -306,6 +306,7 @@ async fn main() -> Result<(), std::io::Error> { history: std::sync::Arc::new(tokio::sync::Mutex::new(history)), history_size: cfg.history_size, window_tracker: Arc::new(chat::transport::whatsapp::MessagingWindowTracker::new()), + allowed_phones: cfg.whatsapp_allowed_phones.clone(), }) });