security: harden architecture against Moltbot security model

- Discord: add allowed_users field + sender validation in listen()
- Slack: add allowed_users field + sender validation in listen()
- Webhook: add X-Webhook-Secret header auth (401 on mismatch)
- SecurityPolicy: add ActionTracker with sliding-window rate limiting
  - record_action() enforces max_actions_per_hour
  - is_rate_limited() checks without recording
- Gateway: print auth status on startup (ENABLED/DISABLED)
- 22 new tests (Discord/Slack allowlists, gateway header extraction,
  rate limiter: starts at zero, records, allows within limit,
  blocks over limit, clone independence)
- 554 tests passing, 0 clippy warnings
This commit is contained in:
argenis de la rosa 2026-02-13 15:31:21 -05:00
parent cf0ca71fdc
commit 542bb80743
7 changed files with 287 additions and 6 deletions

View file

@ -6,18 +6,29 @@ use uuid::Uuid;
pub struct SlackChannel {
bot_token: String,
channel_id: Option<String>,
allowed_users: Vec<String>,
client: reqwest::Client,
}
impl SlackChannel {
pub fn new(bot_token: String, channel_id: Option<String>) -> Self {
pub fn new(bot_token: String, channel_id: Option<String>, allowed_users: Vec<String>) -> Self {
Self {
bot_token,
channel_id,
allowed_users,
client: reqwest::Client::new(),
}
}
/// Check if a Slack user ID is in the allowlist.
/// Empty list or `["*"]` means allow everyone.
fn is_user_allowed(&self, user_id: &str) -> bool {
if self.allowed_users.is_empty() {
return true;
}
self.allowed_users.iter().any(|u| u == "*" || u == user_id)
}
/// Get the bot's own user ID so we can ignore our own messages
async fn get_bot_user_id(&self) -> Option<String> {
let resp: serde_json::Value = self
@ -119,6 +130,12 @@ impl Channel for SlackChannel {
continue;
}
// Sender validation
if !self.is_user_allowed(user) {
tracing::warn!("Slack: ignoring message from unauthorized user: {user}");
continue;
}
// Skip empty or already-seen
if text.is_empty() || ts <= last_ts.as_str() {
continue;
@ -162,13 +179,34 @@ mod tests {
#[test]
fn slack_channel_name() {
let ch = SlackChannel::new("xoxb-fake".into(), None);
let ch = SlackChannel::new("xoxb-fake".into(), None, vec![]);
assert_eq!(ch.name(), "slack");
}
#[test]
fn slack_channel_with_channel_id() {
let ch = SlackChannel::new("xoxb-fake".into(), Some("C12345".into()));
let ch = SlackChannel::new("xoxb-fake".into(), Some("C12345".into()), vec![]);
assert_eq!(ch.channel_id, Some("C12345".to_string()));
}
#[test]
fn empty_allowlist_allows_everyone() {
let ch = SlackChannel::new("xoxb-fake".into(), None, vec![]);
assert!(ch.is_user_allowed("U12345"));
assert!(ch.is_user_allowed("anyone"));
}
#[test]
fn wildcard_allows_everyone() {
let ch = SlackChannel::new("xoxb-fake".into(), None, vec!["*".into()]);
assert!(ch.is_user_allowed("U12345"));
}
#[test]
fn specific_allowlist_filters() {
let ch = SlackChannel::new("xoxb-fake".into(), None, vec!["U111".into(), "U222".into()]);
assert!(ch.is_user_allowed("U111"));
assert!(ch.is_user_allowed("U222"));
assert!(!ch.is_user_allowed("U333"));
}
}