filter: pluggable pattern engine (glob|regex) selected by filter_engine config; regex/glob backends behind a Matcher trait
This commit is contained in:
parent
73817eddde
commit
4357a59c7a
5 changed files with 143 additions and 20 deletions
|
|
@ -121,6 +121,10 @@ use_resolved_host = on
|
||||||
# ban reason and may contain %ip% (the client address). Values may be "quoted".
|
# 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."
|
# 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.
|
# antimixedutf8 — block spam that mixes look-alike scripts within words.
|
||||||
# action = block | kill | gline | kline | zline ; target = both | channel | private
|
# action = block | kill | gline | kline | zline ; target = both | channel | private
|
||||||
antimixedutf8 = off
|
antimixedutf8 = off
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
//! and, on a hit, an action is taken. The rule set lives in `Server.ext`, the
|
//! 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.
|
//! `FILTER` command manages it, and the `on_pre_message` hook enforces it.
|
||||||
|
|
||||||
use crate::channels::glob_match;
|
|
||||||
use crate::command::{CmdResult, Command};
|
use crate::command::{CmdResult, Command};
|
||||||
use crate::module::{ModResult, Module};
|
use crate::module::{ModResult, Module};
|
||||||
use crate::numeric::ERR_NOPRIVILEGES;
|
use crate::numeric::ERR_NOPRIVILEGES;
|
||||||
|
|
@ -11,12 +10,29 @@ use crate::xline::{parse_duration, XKind};
|
||||||
use crate::Uid;
|
use crate::Uid;
|
||||||
|
|
||||||
/// One filter rule.
|
/// One filter rule.
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct SpamFilter {
|
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 action: String, // block | silent | kill | kline | gline | zline
|
||||||
pub duration: u64, // ban length for the *line actions (seconds; 0 = permanent)
|
pub duration: u64, // ban length for the *line actions (seconds; 0 = permanent)
|
||||||
pub reason: String,
|
pub reason: String,
|
||||||
|
matcher: Box<dyn crate::modules::pattern::Matcher>, // 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<SpamFilter, String> {
|
||||||
|
let matcher = crate::modules::pattern::compile(&engine, &pattern)?;
|
||||||
|
Ok(SpamFilter { pattern, engine, action, duration, reason, matcher })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The rule set, stored in `Server.ext`.
|
/// The rule set, stored in `Server.ext`.
|
||||||
|
|
@ -24,11 +40,11 @@ pub struct SpamFilter {
|
||||||
pub struct Filters(pub Vec<SpamFilter>);
|
pub struct Filters(pub Vec<SpamFilter>);
|
||||||
|
|
||||||
impl Filters {
|
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)> {
|
fn hit(&self, text: &str) -> Option<(String, String, u64)> {
|
||||||
self.0
|
self.0
|
||||||
.iter()
|
.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))
|
.map(|f| (f.action.clone(), f.reason.clone(), f.duration))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -121,7 +137,7 @@ impl Command for FilterCmd {
|
||||||
.map(|f| {
|
.map(|f| {
|
||||||
f.0.iter()
|
f.0.iter()
|
||||||
.map(|r| {
|
.map(|r| {
|
||||||
format!("{} {} {} :{}", r.pattern, r.action, r.duration, r.reason)
|
format!("{} [{}] {} {} :{}", r.pattern, r.engine, r.action, r.duration, r.reason)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
|
|
@ -163,18 +179,42 @@ impl Command for FilterCmd {
|
||||||
.unwrap_or_else(|| "Filtered".to_string()),
|
.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>(Filters::default);
|
let f = s.ext.get_or_insert_with::<Filters>(Filters::default);
|
||||||
f.0.retain(|r| r.pattern != pattern);
|
f.0.retain(|r| r.pattern != pattern);
|
||||||
f.0.push(SpamFilter {
|
f.0.push(filter);
|
||||||
pattern: pattern.clone(),
|
s.snotice_c('f', &format!("{nick} added FILTER {pattern} (engine={engine} action={action})"));
|
||||||
action: action.clone(),
|
|
||||||
duration,
|
|
||||||
reason,
|
|
||||||
});
|
|
||||||
s.snotice_c('f', &format!("{nick} added FILTER {pattern} (action={action})"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CmdResult::Ok
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ pub mod operlevels;
|
||||||
pub mod operprefix;
|
pub mod operprefix;
|
||||||
pub mod opertypes;
|
pub mod opertypes;
|
||||||
pub mod password_hash;
|
pub mod password_hash;
|
||||||
|
pub mod pattern;
|
||||||
pub mod permchannels;
|
pub mod permchannels;
|
||||||
pub mod profilelink;
|
pub mod profilelink;
|
||||||
pub mod randquote;
|
pub mod randquote;
|
||||||
|
|
|
||||||
76
src/modules/pattern.rs
Normal file
76
src/modules/pattern.rs
Normal file
|
|
@ -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<Box<dyn Matcher>, 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ pub fn handle(s: &mut Server, action: &str, params: &str) -> Result<String, RpcE
|
||||||
.map(|r| {
|
.map(|r| {
|
||||||
obj(&[
|
obj(&[
|
||||||
("pattern", qstr(&r.pattern)),
|
("pattern", qstr(&r.pattern)),
|
||||||
|
("engine", qstr(&r.engine)),
|
||||||
("reason", qstr(&r.reason)),
|
("reason", qstr(&r.reason)),
|
||||||
("action", qstr(&r.action)),
|
("action", qstr(&r.action)),
|
||||||
("duration", r.duration.to_string()),
|
("duration", r.duration.to_string()),
|
||||||
|
|
@ -36,16 +37,17 @@ pub fn handle(s: &mut Server, action: &str, params: &str) -> Result<String, RpcE
|
||||||
let action = json::get_str(params, "action").unwrap_or_else(|| "block".into());
|
let action = json::get_str(params, "action").unwrap_or_else(|| "block".into());
|
||||||
let reason = json::get_str(params, "reason").unwrap_or_else(|| "Set via RPC".into());
|
let reason = json::get_str(params, "reason").unwrap_or_else(|| "Set via RPC".into());
|
||||||
let duration = json::get_num::<u64>(params, "duration").unwrap_or(0);
|
let duration = json::get_num::<u64>(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>(Filters::default);
|
let set = s.ext.get_or_insert_with::<Filters>(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"));
|
return Err(RpcError::not_found("filter already exists"));
|
||||||
}
|
}
|
||||||
set.0.push(SpamFilter {
|
set.0.push(filter);
|
||||||
pattern,
|
|
||||||
action,
|
|
||||||
duration,
|
|
||||||
reason,
|
|
||||||
});
|
|
||||||
Ok(obj(&[("result", "true".into())]))
|
Ok(obj(&[("result", "true".into())]))
|
||||||
}
|
}
|
||||||
"del" => {
|
"del" => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue