storkit: merge 389_story_whatsapp_phone_number_allowlist_authorization

This commit is contained in:
dave
2026-03-25 13:39:44 +00:00
parent 60c0c95f38
commit 84a775be77
5 changed files with 166 additions and 0 deletions

View File

@@ -26,3 +26,8 @@ whatsapp_verify_token = "my-secret-verify-token"
# Maximum conversation turns to remember per user (default: 20). # Maximum conversation turns to remember per user (default: 20).
# history_size = 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"]

View File

@@ -22,3 +22,8 @@ twilio_whatsapp_number = "+14155238886"
# Maximum conversation turns to remember per user (default: 20). # Maximum conversation turns to remember per user (default: 20).
# history_size = 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"]

View File

@@ -114,6 +114,14 @@ pub struct BotConfig {
#[serde(default)] #[serde(default)]
pub twilio_whatsapp_number: Option<String>, pub twilio_whatsapp_number: Option<String>,
/// 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<String>,
// ── Slack Bot API fields ───────────────────────────────────────── // ── Slack Bot API fields ─────────────────────────────────────────
// These are only required when `transport = "slack"`. // These are only required when `transport = "slack"`.
@@ -1010,4 +1018,40 @@ slack_signing_secret = "secret123"
.unwrap(); .unwrap();
assert!(BotConfig::load(tmp.path()).is_none()); 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"]
);
}
} }

View File

@@ -881,6 +881,9 @@ pub struct WhatsAppWebhookContext {
pub history_size: usize, pub history_size: usize,
/// Tracks the 24-hour messaging window per user phone number. /// Tracks the 24-hour messaging window per user phone number.
pub window_tracker: Arc<MessagingWindowTracker>, pub window_tracker: Arc<MessagingWindowTracker>,
/// Phone numbers allowed to send messages to the bot.
/// When empty, all numbers are allowed (backwards compatible).
pub allowed_phones: Vec<String>,
} }
/// GET /webhook/whatsapp — webhook verification. /// 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) { async fn handle_incoming_message(ctx: &WhatsAppWebhookContext, sender: &str, message: &str) {
use crate::chat::transport::matrix::commands::{CommandDispatch, try_handle_command}; 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. // Record this inbound message to keep the 24-hour window open.
ctx.window_tracker.record_message(sender); ctx.window_tracker.record_message(sender);
@@ -1820,6 +1831,106 @@ mod tests {
let _msgs = extract_twilio_text_messages(body); let _msgs = extract_twilio_text_messages(body);
} }
// ── Allowlist tests ───────────────────────────────────────────────────
/// Build a minimal WhatsAppWebhookContext for allowlist tests.
fn make_ctx_with_allowlist(
allowed_phones: Vec<String>,
) -> Arc<WhatsAppWebhookContext> {
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<crate::chat::MessageId, String> {
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::<WatcherEvent>(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] #[test]
fn load_whatsapp_history_returns_empty_when_file_missing() { fn load_whatsapp_history_returns_empty_when_file_missing() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();

View File

@@ -306,6 +306,7 @@ async fn main() -> Result<(), std::io::Error> {
history: std::sync::Arc::new(tokio::sync::Mutex::new(history)), history: std::sync::Arc::new(tokio::sync::Mutex::new(history)),
history_size: cfg.history_size, history_size: cfg.history_size,
window_tracker: Arc::new(chat::transport::whatsapp::MessagingWindowTracker::new()), window_tracker: Arc::new(chat::transport::whatsapp::MessagingWindowTracker::new()),
allowed_phones: cfg.whatsapp_allowed_phones.clone(),
}) })
}); });