BotServ BADWORDS: user-configurable regex kicker

BADWORDS <#channel> ADD|DEL|LIST|CLEAR manages a channel's badword
patterns, each a regular expression, so a channel matches whatever it
wants — an improvement over Anope's fixed ANY/SINGLE/START/END. Enable
with KICK <#channel> BADWORDS ON.

Uses the regex crate: it's finite-automata based with linear-time
matching, so untrusted user patterns can't cause catastrophic
backtracking (ReDoS). A channel's patterns compile into one RegexSet
(single-pass match against all of them), cached in the engine and rebuilt
only when the list's revision changes — so the hot path is one is_match
call, no per-message compilation or allocation. Patterns are validated
(and size-limited) at add time; matching is case-insensitive by default.
This commit is contained in:
Jean Chevronnet 2026-07-13 16:16:44 +00:00
parent 43def8ee23
commit ca95184359
No known key found for this signature in database
9 changed files with 238 additions and 7 deletions

1
Cargo.lock generated
View file

@ -294,6 +294,7 @@ dependencies = [
"hmac", "hmac",
"pbkdf2", "pbkdf2",
"prost", "prost",
"regex",
"rustls-pemfile", "rustls-pemfile",
"serde", "serde",
"serde_json", "serde_json",

View file

@ -25,6 +25,7 @@ sha2 = "0.10"
hmac = "0.12" hmac = "0.12"
pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] } pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] }
subtle = "2" subtle = "2"
regex = "1"
anyhow = "1" anyhow = "1"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View file

@ -414,6 +414,8 @@ pub enum Kicker {
Italics, Italics,
Flood, Flood,
Repeat, Repeat,
// Kick lines matching a configured badword regex (patterns via BADWORDS).
Badwords,
// Exemption, not a rule: never kick channel operators. // Exemption, not a rule: never kick channel operators.
DontKickOps, DontKickOps,
} }
@ -490,6 +492,7 @@ pub enum CertError {
pub enum ChanError { pub enum ChanError {
Exists, // channel already registered Exists, // channel already registered
NoChannel, // channel is not registered NoChannel, // channel is not registered
InvalidPattern, // a badword regex didn't compile
Internal, // persistence failed Internal, // persistence failed
} }
@ -553,6 +556,11 @@ pub trait Store {
fn set_caps_kicker(&mut self, channel: &str, caps_min: u16, caps_percent: u16) -> Result<(), ChanError>; fn set_caps_kicker(&mut self, channel: &str, caps_min: u16, caps_percent: u16) -> Result<(), ChanError>;
fn set_flood_kicker(&mut self, channel: &str, lines: u16, secs: u16) -> Result<(), ChanError>; fn set_flood_kicker(&mut self, channel: &str, lines: u16, secs: u16) -> Result<(), ChanError>;
fn set_repeat_kicker(&mut self, channel: &str, times: u16) -> Result<(), ChanError>; fn set_repeat_kicker(&mut self, channel: &str, times: u16) -> Result<(), ChanError>;
// BADWORDS list (regex patterns). add validates the pattern compiles.
fn badword_add(&mut self, channel: &str, pattern: &str) -> Result<bool, ChanError>;
fn badword_del(&mut self, channel: &str, pattern: &str) -> Result<bool, ChanError>;
fn badword_clear(&mut self, channel: &str) -> Result<usize, ChanError>;
fn badwords(&self, channel: &str) -> Vec<String>;
fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError>; fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError>;
fn suspend_channel(&mut self, channel: &str, by: &str, reason: &str, expires: Option<u64>) -> Result<(), ChanError>; fn suspend_channel(&mut self, channel: &str, by: &str, reason: &str, expires: Option<u64>) -> Result<(), ChanError>;
fn unsuspend_channel(&mut self, channel: &str) -> Result<bool, ChanError>; fn unsuspend_channel(&mut self, channel: &str) -> Result<bool, ChanError>;

63
botserv/src/badwords.rs Normal file
View file

@ -0,0 +1,63 @@
use fedserv_api::{ChanError, Sender, ServiceCtx, Store};
// BADWORDS <#channel> ADD <regex> | DEL <regex> | LIST | CLEAR: manage the
// channel's badword patterns. Each entry is a regular expression, so a channel
// can match whatever it likes (improving on Anope's fixed ANY/SINGLE/START/END).
// Enable the kicker itself with KICK <#channel> BADWORDS ON. Founder-or-admin.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(&chan) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: BADWORDS <#channel> ADD|DEL|LIST|CLEAR [pattern]");
return;
};
// LIST is readable by the founder/admin too; gate everything the same way.
if !super::require_channel_admin(me, from, chan, ctx, db) {
return;
}
match args.get(2).map(|s| s.to_ascii_uppercase()).as_deref() {
Some("ADD") => {
if args.len() < 4 {
ctx.notice(me, from.uid, "Syntax: BADWORDS <#channel> ADD <regex>");
return;
}
let pattern = args[3..].join(" ");
match db.badword_add(chan, &pattern) {
Ok(true) => ctx.notice(me, from.uid, format!("Added badword pattern to \x02{chan}\x02: {pattern}")),
Ok(false) => ctx.notice(me, from.uid, "That pattern is already on the list."),
Err(ChanError::InvalidPattern) => ctx.notice(me, from.uid, format!("\x02{pattern}\x02 isn't a valid regular expression.")),
Err(_) => reg_error(me, from, chan, ctx),
}
}
Some("DEL") => {
if args.len() < 4 {
ctx.notice(me, from.uid, "Syntax: BADWORDS <#channel> DEL <regex>");
return;
}
let pattern = args[3..].join(" ");
match db.badword_del(chan, &pattern) {
Ok(true) => ctx.notice(me, from.uid, format!("Removed badword pattern from \x02{chan}\x02.")),
Ok(false) => ctx.notice(me, from.uid, "That pattern isn't on the list."),
Err(_) => reg_error(me, from, chan, ctx),
}
}
Some("CLEAR") => match db.badword_clear(chan) {
Ok(n) => ctx.notice(me, from.uid, format!("Cleared \x02{n}\x02 badword pattern(s) from \x02{chan}\x02.")),
Err(_) => reg_error(me, from, chan, ctx),
},
None | Some("LIST") => {
let words = db.badwords(chan);
if words.is_empty() {
ctx.notice(me, from.uid, format!("\x02{chan}\x02 has no badword patterns."));
return;
}
ctx.notice(me, from.uid, format!("Badword patterns for \x02{chan}\x02 ({}):", words.len()));
for (i, w) in words.iter().enumerate() {
ctx.notice(me, from.uid, format!(" {}. {w}", i + 1));
}
}
Some(other) => ctx.notice(me, from.uid, format!("Unknown BADWORDS command \x02{other}\x02. Use ADD, DEL, LIST or CLEAR.")),
}
}
fn reg_error(me: &str, from: &Sender, chan: &str, ctx: &mut ServiceCtx) {
ctx.notice(me, from.uid, format!("Couldn't update \x02{chan}\x02 — please try again in a moment."));
}

View file

@ -66,6 +66,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
"UNDERLINES" => Kicker::Underlines, "UNDERLINES" => Kicker::Underlines,
"REVERSES" => Kicker::Reverses, "REVERSES" => Kicker::Reverses,
"ITALICS" => Kicker::Italics, "ITALICS" => Kicker::Italics,
"BADWORDS" => Kicker::Badwords,
"DONTKICKOPS" => Kicker::DontKickOps, "DONTKICKOPS" => Kicker::DontKickOps,
other => { other => {
ctx.notice(me, from.uid, format!("Unknown kicker \x02{other}\x02. Try CAPS, FLOOD, REPEAT, BOLDS, COLORS, UNDERLINES, REVERSES, ITALICS or DONTKICKOPS.")); ctx.notice(me, from.uid, format!("Unknown kicker \x02{other}\x02. Try CAPS, FLOOD, REPEAT, BOLDS, COLORS, UNDERLINES, REVERSES, ITALICS or DONTKICKOPS."));
@ -85,6 +86,7 @@ fn label(kicker: Kicker) -> &'static str {
Kicker::Italics => "italics", Kicker::Italics => "italics",
Kicker::Flood => "flood", Kicker::Flood => "flood",
Kicker::Repeat => "repeat", Kicker::Repeat => "repeat",
Kicker::Badwords => "badwords",
Kicker::DontKickOps => "dontkickops", Kicker::DontKickOps => "dontkickops",
} }
} }

View file

@ -16,6 +16,8 @@ mod say;
mod set; mod set;
#[path = "kick.rs"] #[path = "kick.rs"]
mod kick; mod kick;
#[path = "badwords.rs"]
mod badwords;
// Shared gate: the sender may administer <chan>'s bot options only as its // Shared gate: the sender may administer <chan>'s bot options only as its
// founder or a services admin. Notices and returns false on failure. // founder or a services admin. Notices and returns false on failure.
@ -57,7 +59,8 @@ impl Service for BotServ {
Some("ACT") => say::handle(me, from, args, ctx, net, db, true), Some("ACT") => say::handle(me, from, args, ctx, net, db, true),
Some("SET") => set::handle(me, from, args, ctx, db), Some("SET") => set::handle(me, from, args, ctx, db),
Some("KICK") => kick::handle(me, from, args, ctx, db), Some("KICK") => kick::handle(me, from, args, ctx, db),
Some("HELP") | None => ctx.notice(me, from.uid, "BotServ keeps service bots for your channels: \x02ASSIGN\x02 <#channel> <bot> puts a bot in your channel, \x02UNASSIGN\x02 <#channel> removes it, \x02INFO\x02 <bot|#channel> shows details, \x02SAY\x02/\x02ACT\x02 <#channel> <text> speaks through the bot, \x02SET\x02 <#channel> GREET <on|off> toggles greets, \x02KICK\x02 <#channel> <type> <on|off> configures kickers. Operators also have \x02BOT\x02 ADD|DEL|LIST."), Some("BADWORDS") => badwords::handle(me, from, args, ctx, db),
Some("HELP") | None => ctx.notice(me, from.uid, "BotServ keeps service bots for your channels: \x02ASSIGN\x02 <#channel> <bot> puts a bot in your channel, \x02UNASSIGN\x02 <#channel> removes it, \x02INFO\x02 <bot|#channel> shows details, \x02SAY\x02/\x02ACT\x02 <#channel> <text> speaks through the bot, \x02SET\x02 <#channel> GREET <on|off> toggles greets, \x02KICK\x02 <#channel> <type> <on|off> configures kickers, \x02BADWORDS\x02 <#channel> ADD|DEL|LIST manages badword regexes. Operators also have \x02BOT\x02 ADD|DEL|LIST."),
Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")), Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")),
} }
} }

View file

@ -1,6 +1,10 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::io::Write; use std::io::Write;
use std::path::PathBuf; use std::path::PathBuf;
// Ceiling on a single compiled badword regex, so one pattern can't consume huge
// memory. Shared by add-time validation and the engine's match-time build.
pub const BADWORD_SIZE_LIMIT: usize = 1 << 20;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use argon2::password_hash::rand_core::{OsRng, RngCore}; use argon2::password_hash::rand_core::{OsRng, RngCore};
@ -99,6 +103,7 @@ pub enum Event {
ChannelEntryMsgSet { channel: String, msg: String }, ChannelEntryMsgSet { channel: String, msg: String },
ChannelSettingsSet { channel: String, settings: ChanSettings }, ChannelSettingsSet { channel: String, settings: ChanSettings },
ChannelKickerSet { channel: String, kickers: KickerSettings }, ChannelKickerSet { channel: String, kickers: KickerSettings },
ChannelBadwordsSet { channel: String, badwords: Vec<String> },
ChannelTopicSet { channel: String, topic: String }, ChannelTopicSet { channel: String, topic: String },
ChannelSuspended { channel: String, by: String, reason: String, ts: u64, expires: Option<u64> }, ChannelSuspended { channel: String, by: String, reason: String, ts: u64, expires: Option<u64> },
ChannelUnsuspended { channel: String }, ChannelUnsuspended { channel: String },
@ -150,6 +155,7 @@ impl Event {
| Event::ChannelEntryMsgSet { .. } | Event::ChannelEntryMsgSet { .. }
| Event::ChannelSettingsSet { .. } | Event::ChannelSettingsSet { .. }
| Event::ChannelKickerSet { .. } | Event::ChannelKickerSet { .. }
| Event::ChannelBadwordsSet { .. }
| Event::ChannelTopicSet { .. } | Event::ChannelTopicSet { .. }
| Event::ChannelSuspended { .. } | Event::ChannelSuspended { .. }
| Event::ChannelUnsuspended { .. } | Event::ChannelUnsuspended { .. }
@ -277,6 +283,13 @@ pub struct ChannelInfo {
// BotServ kicker configuration (the bot kicks on caps/formatting/etc.). // BotServ kicker configuration (the bot kicks on caps/formatting/etc.).
#[serde(default)] #[serde(default)]
pub kickers: KickerSettings, pub kickers: KickerSettings,
// BADWORDS: user-configured regex patterns the badwords kicker matches.
#[serde(default)]
pub badwords: Vec<String>,
// Bumped whenever `badwords` changes, so the engine's compiled-RegexSet
// cache knows to rebuild. Session-local; never serialized.
#[serde(skip)]
pub badwords_rev: u64,
} }
// BotServ's per-channel "kickers": the assigned bot kicks a message that trips // BotServ's per-channel "kickers": the assigned bot kicks a message that trips
@ -315,6 +328,9 @@ pub struct KickerSettings {
// Consecutive repeats that trip the repeat kicker (0 = default, 3). // Consecutive repeats that trip the repeat kicker (0 = default, 3).
#[serde(default)] #[serde(default)]
pub repeat_times: u16, pub repeat_times: u16,
// Kick lines matching one of the channel's badword regexes.
#[serde(default)]
pub badwords: bool,
// Don't kick channel operators, whatever they send. // Don't kick channel operators, whatever they send.
#[serde(default)] #[serde(default)]
pub dontkickops: bool, pub dontkickops: bool,
@ -323,7 +339,7 @@ pub struct KickerSettings {
impl KickerSettings { impl KickerSettings {
// Any kicker enabled? (dontkickops alone doesn't count.) // Any kicker enabled? (dontkickops alone doesn't count.)
pub fn any(&self) -> bool { pub fn any(&self) -> bool {
self.caps || self.bolds || self.colors || self.underlines || self.reverses || self.italics || self.flood || self.repeat self.caps || self.bolds || self.colors || self.underlines || self.reverses || self.italics || self.flood || self.repeat || self.badwords
} }
// Resolved thresholds, applying Anope's defaults for a 0 (unset) value. // Resolved thresholds, applying Anope's defaults for a 0 (unset) value.
@ -786,6 +802,9 @@ impl Db {
if c.kickers.any() || c.kickers.dontkickops { if c.kickers.any() || c.kickers.dontkickops {
snapshot.push(Event::ChannelKickerSet { channel: c.name.clone(), kickers: c.kickers.clone() }); snapshot.push(Event::ChannelKickerSet { channel: c.name.clone(), kickers: c.kickers.clone() });
} }
if !c.badwords.is_empty() {
snapshot.push(Event::ChannelBadwordsSet { channel: c.name.clone(), badwords: c.badwords.clone() });
}
} }
for (nick, account) in &self.grouped { for (nick, account) in &self.grouped {
snapshot.push(Event::NickGrouped { nick: nick.clone(), account: account.clone() }); snapshot.push(Event::NickGrouped { nick: nick.clone(), account: account.clone() });
@ -1228,7 +1247,7 @@ impl Db {
self.log self.log
.append(Event::ChannelRegistered { name: name.to_string(), founder: founder.to_string(), ts }) .append(Event::ChannelRegistered { name: name.to_string(), founder: founder.to_string(), ts })
.map_err(|_| ChanError::Internal)?; .map_err(|_| ChanError::Internal)?;
self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), desc: String::new(), entrymsg: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() }); self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), desc: String::new(), entrymsg: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 });
Ok(()) Ok(())
} }
@ -1397,6 +1416,7 @@ impl Db {
Kicker::Italics => k.italics = on, Kicker::Italics => k.italics = on,
Kicker::Flood => k.flood = on, Kicker::Flood => k.flood = on,
Kicker::Repeat => k.repeat = on, Kicker::Repeat => k.repeat = on,
Kicker::Badwords => k.badwords = on,
Kicker::DontKickOps => k.dontkickops = on, Kicker::DontKickOps => k.dontkickops = on,
}) })
} }
@ -1427,6 +1447,61 @@ impl Db {
}) })
} }
/// Add a badword regex. Validates it compiles within the size limit; returns
/// Ok(false) if the exact pattern is already present.
pub fn badword_add(&mut self, channel: &str, pattern: &str) -> Result<bool, ChanError> {
if regex::RegexBuilder::new(pattern).size_limit(BADWORD_SIZE_LIMIT).build().is_err() {
return Err(ChanError::InvalidPattern);
}
let k = key(channel);
let Some(c) = self.channels.get(&k) else { return Err(ChanError::NoChannel) };
if c.badwords.iter().any(|w| w == pattern) {
return Ok(false);
}
let mut list = c.badwords.clone();
list.push(pattern.to_string());
self.write_badwords(channel, &k, list)?;
Ok(true)
}
/// Remove a badword by exact pattern. Ok(false) if it wasn't listed.
pub fn badword_del(&mut self, channel: &str, pattern: &str) -> Result<bool, ChanError> {
let k = key(channel);
let Some(c) = self.channels.get(&k) else { return Err(ChanError::NoChannel) };
if !c.badwords.iter().any(|w| w == pattern) {
return Ok(false);
}
let list: Vec<String> = c.badwords.iter().filter(|w| *w != pattern).cloned().collect();
self.write_badwords(channel, &k, list)?;
Ok(true)
}
/// Clear all badwords; returns how many were removed.
pub fn badword_clear(&mut self, channel: &str) -> Result<usize, ChanError> {
let k = key(channel);
let Some(c) = self.channels.get(&k) else { return Err(ChanError::NoChannel) };
let n = c.badwords.len();
if n > 0 {
self.write_badwords(channel, &k, Vec::new())?;
}
Ok(n)
}
pub fn badwords(&self, channel: &str) -> &[String] {
self.channels.get(&key(channel)).map_or(&[], |c| c.badwords.as_slice())
}
// Persist a new badword list (whole-list event) and apply it.
fn write_badwords(&mut self, channel: &str, k: &str, list: Vec<String>) -> Result<(), ChanError> {
self.log
.append(Event::ChannelBadwordsSet { channel: channel.to_string(), badwords: list.clone() })
.map_err(|_| ChanError::Internal)?;
let c = self.channels.get_mut(k).unwrap();
c.badwords = list;
c.badwords_rev = c.badwords_rev.wrapping_add(1);
Ok(())
}
// Read-modify-write the whole kicker struct through one event. // Read-modify-write the whole kicker struct through one event.
fn update_kickers(&mut self, channel: &str, f: impl FnOnce(&mut KickerSettings)) -> Result<(), ChanError> { fn update_kickers(&mut self, channel: &str, f: impl FnOnce(&mut KickerSettings)) -> Result<(), ChanError> {
let k = key(channel); let k = key(channel);
@ -1725,7 +1800,7 @@ fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String,
grouped.remove(&key(&nick)); grouped.remove(&key(&nick));
} }
Event::ChannelRegistered { name, founder, ts } => { Event::ChannelRegistered { name, founder, ts } => {
channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), desc: String::new(), entrymsg: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() }); channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), desc: String::new(), entrymsg: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 });
} }
Event::ChannelDropped { name } => { Event::ChannelDropped { name } => {
channels.remove(&key(&name)); channels.remove(&key(&name));
@ -1778,6 +1853,12 @@ fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String,
c.kickers = kickers; c.kickers = kickers;
} }
} }
Event::ChannelBadwordsSet { channel, badwords } => {
if let Some(c) = channels.get_mut(&key(&channel)) {
c.badwords = badwords;
c.badwords_rev = c.badwords_rev.wrapping_add(1);
}
}
Event::ChannelTopicSet { channel, topic } => { Event::ChannelTopicSet { channel, topic } => {
if let Some(c) = channels.get_mut(&key(&channel)) { if let Some(c) = channels.get_mut(&key(&channel)) {
c.topic = topic; c.topic = topic;
@ -2011,6 +2092,18 @@ impl Store for Db {
fn set_repeat_kicker(&mut self, channel: &str, times: u16) -> Result<(), ChanError> { fn set_repeat_kicker(&mut self, channel: &str, times: u16) -> Result<(), ChanError> {
Db::set_repeat_kicker(self, channel, times) Db::set_repeat_kicker(self, channel, times)
} }
fn badword_add(&mut self, channel: &str, pattern: &str) -> Result<bool, ChanError> {
Db::badword_add(self, channel, pattern)
}
fn badword_del(&mut self, channel: &str, pattern: &str) -> Result<bool, ChanError> {
Db::badword_del(self, channel, pattern)
}
fn badword_clear(&mut self, channel: &str) -> Result<usize, ChanError> {
Db::badword_clear(self, channel)
}
fn badwords(&self, channel: &str) -> Vec<String> {
Db::badwords(self, channel).to_vec()
}
fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError> { fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError> {
Db::set_channel_topic(self, channel, topic) Db::set_channel_topic(self, channel, topic)
} }

View file

@ -104,6 +104,16 @@ pub struct Engine {
// Keyed by the uplink-canonical channel string (never event-logged). // Keyed by the uplink-canonical channel string (never event-logged).
chatter: HashMap<String, HashMap<String, ChatterState>>, chatter: HashMap<String, HashMap<String, ChatterState>>,
now_override: Option<u64>, // test clock (unix secs); None = real wall time now_override: Option<u64>, // test clock (unix secs); None = real wall time
// Compiled BADWORDS matchers, one RegexSet per channel, rebuilt lazily when
// the channel's badword revision changes. A RegexSet matches a line against
// every pattern in a single linear pass (and the regex crate is backtracking-
// free, so user patterns can't blow up). Never event-logged.
badword_cache: HashMap<String, CachedBadwords>,
}
struct CachedBadwords {
rev: u64,
set: regex::RegexSet,
} }
// One user's recent-chatter counters in one channel (FLOOD + REPEAT kickers). // One user's recent-chatter counters in one channel (FLOOD + REPEAT kickers).
@ -135,6 +145,7 @@ impl Engine {
bot_channels: std::collections::HashSet::new(), bot_channels: std::collections::HashSet::new(),
chatter: HashMap::new(), chatter: HashMap::new(),
now_override: None, now_override: None,
badword_cache: HashMap::new(),
} }
} }
@ -948,6 +959,18 @@ impl Engine {
if let Some(reason) = k.violation(text) { if let Some(reason) = k.violation(text) {
return kick(reason); return kick(reason);
} }
// BADWORDS: match the line against the channel's compiled regex set,
// rebuilding the cache only when the badword list has changed.
if k.badwords && !c.badwords.is_empty() {
let rev = c.badwords_rev;
if self.badword_cache.get(channel).map(|cb| cb.rev) != Some(rev) {
let set = build_badword_set(&c.badwords);
self.badword_cache.insert(channel.to_string(), CachedBadwords { rev, set });
}
if self.badword_cache.get(channel).unwrap().set.is_match(text) {
return kick("Watch your language!");
}
}
if !k.flood && !k.repeat { if !k.flood && !k.repeat {
return None; return None;
} }
@ -1079,6 +1102,17 @@ fn ci_hash(text: &str) -> u64 {
h.finish() h.finish()
} }
// Compile a channel's badword patterns into one RegexSet. Case-insensitive by
// default (Anope's default; a pattern can opt out with `(?-i)`), size-limited,
// and tolerant of a bad entry so one pattern can't disable the whole set.
fn build_badword_set(patterns: &[String]) -> regex::RegexSet {
regex::RegexSetBuilder::new(patterns)
.case_insensitive(true)
.size_limit(db::BADWORD_SIZE_LIMIT)
.build()
.unwrap_or_else(|_| regex::RegexSet::empty())
}
fn login_plain(b64: &str, db: &Db) -> Option<String> { fn login_plain(b64: &str, db: &Db) -> Option<String> {
let raw = STANDARD.decode(b64).ok()?; let raw = STANDARD.decode(b64).ok()?;
let parts: Vec<&[u8]> = raw.split(|&b| b == 0).collect(); let parts: Vec<&[u8]> = raw.split(|&b| b == 0).collect();
@ -2091,6 +2125,31 @@ mod tests {
assert!(kicked(&say(&mut e, "e")), "3rd in-window line kicks"); assert!(kicked(&say(&mut e, "e")), "3rd in-window line kicks");
} }
// The badwords kicker matches user-supplied regexes (case-insensitively),
// rebuilds its compiled set when the list changes, and rejects bad patterns.
#[test]
fn botserv_badwords_kicker_kicks() {
let (mut e, _p) = kicker_fixture("bsbadwords");
let bs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() });
let say = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "#c".into(), text: t.into() });
let kicked = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::Kick { from, uid, .. } if from.starts_with("42SB") && uid == "000AAAAAS"));
bs(&mut e, "BADWORDS #c ADD fr[ao]g");
bs(&mut e, "KICK #c BADWORDS ON");
// Matching line (case-insensitive) is kicked; clean line is not.
assert!(kicked(&say(&mut e, "i love FROGS")), "matched badword kicked");
assert!(!kicked(&say(&mut e, "hello world")), "clean line ok");
// Adding a pattern bumps the revision, so the cached set rebuilds.
bs(&mut e, "BADWORDS #c ADD wibble");
assert!(kicked(&say(&mut e, "wibble wobble")), "new pattern matches after rebuild");
// An invalid regex is rejected and not stored.
let out = bs(&mut e, "BADWORDS #c ADD [unclosed");
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("valid regular expression"))), "invalid rejected: {out:?}");
assert!(!say(&mut e, "[unclosed bracket text").iter().any(|a| matches!(a, NetAction::Kick { .. })), "bad pattern not stored");
}
// Registers #c with founder boss (admin), a live assigned bot Bendy, and // Registers #c with founder boss (admin), a live assigned bot Bendy, and
// returns the engine ready for KICK configuration + a spammer uid 000AAAAAS. // returns the engine ready for KICK configuration + a spammer uid 000AAAAAS.
fn kicker_fixture(tag: &str) -> (Engine, std::path::PathBuf) { fn kicker_fixture(tag: &str) -> (Engine, std::path::PathBuf) {

View file

@ -138,6 +138,7 @@ fn to_wire(entry: &LogEntry) -> Option<ReplicationEvent> {
| Event::ChannelEntryMsgSet { .. } | Event::ChannelEntryMsgSet { .. }
| Event::ChannelSettingsSet { .. } | Event::ChannelSettingsSet { .. }
| Event::ChannelKickerSet { .. } | Event::ChannelKickerSet { .. }
| Event::ChannelBadwordsSet { .. }
| Event::ChannelTopicSet { .. } | Event::ChannelTopicSet { .. }
| Event::ChannelSuspended { .. } | Event::ChannelSuspended { .. }
| Event::ChannelUnsuspended { .. } | Event::ChannelUnsuspended { .. }