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:
parent
cf0ca71fdc
commit
542bb80743
7 changed files with 287 additions and 6 deletions
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -250,6 +250,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
|||
channels.push(Arc::new(DiscordChannel::new(
|
||||
dc.bot_token.clone(),
|
||||
dc.guild_id.clone(),
|
||||
dc.allowed_users.clone(),
|
||||
)));
|
||||
}
|
||||
|
||||
|
|
@ -257,6 +258,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
|||
channels.push(Arc::new(SlackChannel::new(
|
||||
sl.bot_token.clone(),
|
||||
sl.channel_id.clone(),
|
||||
sl.allowed_users.clone(),
|
||||
)));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue