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

@ -9,18 +9,29 @@ use uuid::Uuid;
pub struct DiscordChannel {
bot_token: String,
guild_id: Option<String>,
allowed_users: Vec<String>,
client: reqwest::Client,
}
impl DiscordChannel {
pub fn new(bot_token: String, guild_id: Option<String>) -> Self {
pub fn new(bot_token: String, guild_id: Option<String>, allowed_users: Vec<String>) -> Self {
Self {
bot_token,
guild_id,
allowed_users,
client: reqwest::Client::new(),
}
}
/// Check if a Discord 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)
}
fn bot_user_id_from_token(token: &str) -> Option<String> {
// Discord bot tokens are base64(bot_user_id).timestamp.hmac
let part = token.split('.').next()?;
@ -197,6 +208,12 @@ impl Channel for DiscordChannel {
continue;
}
// Sender validation
if !self.is_user_allowed(author_id) {
tracing::warn!("Discord: ignoring message from unauthorized user: {author_id}");
continue;
}
// Guild filter
if let Some(ref gid) = guild_filter {
let msg_guild = d.get("guild_id").and_then(serde_json::Value::as_str).unwrap_or("");
@ -250,7 +267,7 @@ mod tests {
#[test]
fn discord_channel_name() {
let ch = DiscordChannel::new("fake".into(), None);
let ch = DiscordChannel::new("fake".into(), None, vec![]);
assert_eq!(ch.name(), "discord");
}
@ -268,4 +285,27 @@ mod tests {
let id = DiscordChannel::bot_user_id_from_token(token);
assert_eq!(id, Some("123456".to_string()));
}
#[test]
fn empty_allowlist_allows_everyone() {
let ch = DiscordChannel::new("fake".into(), None, vec![]);
assert!(ch.is_user_allowed("12345"));
assert!(ch.is_user_allowed("anyone"));
}
#[test]
fn wildcard_allows_everyone() {
let ch = DiscordChannel::new("fake".into(), None, vec!["*".into()]);
assert!(ch.is_user_allowed("12345"));
assert!(ch.is_user_allowed("anyone"));
}
#[test]
fn specific_allowlist_filters() {
let ch = DiscordChannel::new("fake".into(), None, vec!["111".into(), "222".into()]);
assert!(ch.is_user_allowed("111"));
assert!(ch.is_user_allowed("222"));
assert!(!ch.is_user_allowed("333"));
assert!(!ch.is_user_allowed("unknown"));
}
}