diff --git a/echoircd.conf.example b/echoircd.conf.example index 61f7f92..a0c5adb 100644 --- a/echoircd.conf.example +++ b/echoircd.conf.example @@ -121,6 +121,10 @@ use_resolved_host = on # ban reason and may contain %ip% (the client address). Values may be "quoted". # dnsbl = domain=torexit.dan.me.uk name="Tor exit node" action=zline duration=1w reason="Tor exit nodes are not allowed on this network. See https://metrics.torproject.org/rs.html#search/%ip% for more information." +# The pattern engine oper /FILTER rules are compiled with: glob (wildcards, the +# default) or regex (a full regular expression). Applies to filters added after it. +# filter_engine = glob + # antimixedutf8 — block spam that mixes look-alike scripts within words. # action = block | kill | gline | kline | zline ; target = both | channel | private antimixedutf8 = off diff --git a/src/modules/filter.rs b/src/modules/filter.rs index dd05344..1bb4bde 100644 --- a/src/modules/filter.rs +++ b/src/modules/filter.rs @@ -2,7 +2,6 @@ //! and, on a hit, an action is taken. The rule set lives in `Server.ext`, the //! `FILTER` command manages it, and the `on_pre_message` hook enforces it. -use crate::channels::glob_match; use crate::command::{CmdResult, Command}; use crate::module::{ModResult, Module}; use crate::numeric::ERR_NOPRIVILEGES; @@ -11,12 +10,29 @@ use crate::xline::{parse_duration, XKind}; use crate::Uid; /// One filter rule. -#[derive(Clone)] pub struct SpamFilter { - pub pattern: String, // glob matched against message text + pub pattern: String, // matched against message text via `engine` + pub engine: String, // pattern engine: "glob" (default) or "regex" pub action: String, // block | silent | kill | kline | gline | zline pub duration: u64, // ban length for the *line actions (seconds; 0 = permanent) pub reason: String, + matcher: Box, // compiled `pattern` for `engine` +} + +impl SpamFilter { + /// Build a rule, compiling `pattern` with `engine` (`glob` or `regex`). Errors + /// (as a message string) if the engine is unknown or a regex is invalid, so a + /// bad rule is refused when set rather than silently never matching. + pub fn new( + pattern: String, + engine: String, + action: String, + duration: u64, + reason: String, + ) -> Result { + let matcher = crate::modules::pattern::compile(&engine, &pattern)?; + Ok(SpamFilter { pattern, engine, action, duration, reason, matcher }) + } } /// The rule set, stored in `Server.ext`. @@ -24,11 +40,11 @@ pub struct SpamFilter { pub struct Filters(pub Vec); impl Filters { - /// The (action, reason, duration) of the first rule whose glob matches `text`. + /// The (action, reason, duration) of the first rule whose pattern matches `text`. fn hit(&self, text: &str) -> Option<(String, String, u64)> { self.0 .iter() - .find(|f| glob_match(&f.pattern, text)) + .find(|f| f.matcher.is_match(text)) .map(|f| (f.action.clone(), f.reason.clone(), f.duration)) } } @@ -121,7 +137,7 @@ impl Command for FilterCmd { .map(|f| { f.0.iter() .map(|r| { - format!("{} {} {} :{}", r.pattern, r.action, r.duration, r.reason) + format!("{} [{}] {} {} :{}", r.pattern, r.engine, r.action, r.duration, r.reason) }) .collect() }) @@ -163,18 +179,42 @@ impl Command for FilterCmd { .unwrap_or_else(|| "Filtered".to_string()), ), }; + // Compile with the configured engine (glob default), rejecting a + // bad rule now rather than having it silently never match. + let engine = s.conf("filter_engine").unwrap_or("glob").to_string(); + let filter = match SpamFilter::new(pattern.clone(), engine.clone(), action.clone(), duration, reason) { + Ok(f) => f, + Err(e) => { + s.send(uid, format!(":{} NOTICE {nick} :FILTER rejected ({e})", s.name)); + return CmdResult::Fail; + } + }; let f = s.ext.get_or_insert_with::(Filters::default); f.0.retain(|r| r.pattern != pattern); - f.0.push(SpamFilter { - pattern: pattern.clone(), - action: action.clone(), - duration, - reason, - }); - s.snotice_c('f', &format!("{nick} added FILTER {pattern} (action={action})")); + f.0.push(filter); + s.snotice_c('f', &format!("{nick} added FILTER {pattern} (engine={engine} action={action})")); } } } CmdResult::Ok } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn filter_matches_by_engine() { + let mut filters = Filters::default(); + filters.0.push(SpamFilter::new("*buy now*".into(), "glob".into(), "block".into(), 0, "spam".into()).unwrap()); + filters.0.push(SpamFilter::new("free.*money".into(), "regex".into(), "kill".into(), 0, "scam".into()).unwrap()); + // glob is case-insensitive wildcard matching + assert!(filters.hit("hey BUY NOW cheap").is_some(), "glob rule matches"); + // regex is a full expression (unanchored substring search) + assert_eq!(filters.hit("get free money here").map(|(a, _, _)| a), Some("kill".into()), "regex rule matches"); + assert!(filters.hit("an ordinary message").is_none(), "no rule matches clean text"); + // an invalid regex is refused when the rule is built + assert!(SpamFilter::new("(oops".into(), "regex".into(), "block".into(), 0, "x".into()).is_err()); + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 53e7054..1401436 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -56,6 +56,7 @@ pub mod operlevels; pub mod operprefix; pub mod opertypes; pub mod password_hash; +pub mod pattern; pub mod permchannels; pub mod profilelink; pub mod randquote; diff --git a/src/modules/pattern.rs b/src/modules/pattern.rs new file mode 100644 index 0000000..c60041c --- /dev/null +++ b/src/modules/pattern.rs @@ -0,0 +1,76 @@ +//! Pluggable pattern engines: match text against a pattern using either simple +//! wildcard globs or full regular expressions, selected by name from config. +//! +//! Modules that test user input against admin-set patterns (e.g. the spam +//! `filter`) compile a pattern once via [`compile`] — rejecting a bad one at set +//! time — then call [`Matcher::is_match`] on the hot path, so the engine choice +//! costs a single virtual call and no per-message compilation. New engines slot in +//! by adding an arm to [`compile`] and a name to [`ENGINES`]. + +use crate::channels::glob_match; +use crate::regex::Regex; + +/// A compiled pattern that can test text for a match. `Send` so it can live in +/// `Server.ext` alongside the rest of a module's state. +pub trait Matcher: Send { + fn is_match(&self, text: &str) -> bool; +} + +/// Wildcard glob (`*` / `?`), the default — case-insensitive like the rest of the +/// ircd's mask matching. +struct GlobMatcher(String); +impl Matcher for GlobMatcher { + fn is_match(&self, text: &str) -> bool { + glob_match(&self.0, text) + } +} + +/// Full regular expression (the same engine RLINE uses). +struct RegexMatcher(Regex); +impl Matcher for RegexMatcher { + fn is_match(&self, text: &str) -> bool { + self.0.is_match(text) + } +} + +/// The engine names selectable in config (for help text / error messages). +pub const ENGINES: &[&str] = &["glob", "regex"]; + +/// Compile `pattern` for the named `engine`: `glob` (default, wildcards) or +/// `regex` (a full regular expression). Errors on an unknown engine or an invalid +/// regex, so a bad rule is refused when it's set rather than silently never matching. +pub fn compile(engine: &str, pattern: &str) -> Result, String> { + match engine { + "glob" | "" => Ok(Box::new(GlobMatcher(pattern.to_string()))), + "regex" => Ok(Box::new(RegexMatcher(Regex::new(pattern)?))), + other => Err(format!( + "unknown pattern engine '{other}' (use one of: {})", + ENGINES.join(", ") + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn glob_and_regex_engines_match() { + let g = compile("glob", "*spam*").unwrap(); + assert!(g.is_match("this is SPAM here")); // wildcards, case-insensitive + assert!(!g.is_match("clean text")); + + let r = compile("regex", "spam.*bot").unwrap(); + assert!(r.is_match("a spam sending bot")); // full regex (case-sensitive, substring) + assert!(!r.is_match("nothing to see")); + + // default (empty) engine is glob + assert!(compile("", "*x*").unwrap().is_match("axb")); + } + + #[test] + fn bad_engine_or_regex_is_rejected() { + assert!(compile("pcre", ".*").is_err()); // unknown engine + assert!(compile("regex", "(unclosed").is_err()); // invalid regex + } +} diff --git a/src/modules/rpc/spamfilter.rs b/src/modules/rpc/spamfilter.rs index 59bc1c9..9867dcc 100644 --- a/src/modules/rpc/spamfilter.rs +++ b/src/modules/rpc/spamfilter.rs @@ -19,6 +19,7 @@ pub fn handle(s: &mut Server, action: &str, params: &str) -> Result Result(params, "duration").unwrap_or(0); + // engine: an explicit param, else the configured default, else glob. + let engine = json::get_str(params, "engine") + .or_else(|| s.conf("filter_engine").map(str::to_string)) + .unwrap_or_else(|| "glob".to_string()); + let filter = SpamFilter::new(pattern, engine, action, duration, reason) + .map_err(|e| RpcError::invalid_params(&e))?; let set = s.ext.get_or_insert_with::(Filters::default); - if set.0.iter().any(|f| f.pattern == pattern) { + if set.0.iter().any(|f| f.pattern == filter.pattern) { return Err(RpcError::not_found("filter already exists")); } - set.0.push(SpamFilter { - pattern, - action, - duration, - reason, - }); + set.0.push(filter); Ok(obj(&[("result", "true".into())])) } "del" => {