storkit: merge 389_story_whatsapp_phone_number_allowlist_authorization
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -114,6 +114,14 @@ pub struct BotConfig {
|
||||
#[serde(default)]
|
||||
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 ─────────────────────────────────────────
|
||||
// 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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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.
|
||||
@@ -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<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]
|
||||
fn load_whatsapp_history_returns_empty_when_file_missing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user