diff --git a/src/channels.rs b/src/channels.rs index a45845e..4a49419 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -529,6 +529,10 @@ impl Server { return; } } + // denychans — a configured forbidden channel name (opers bypass per badchan) + if crate::modules::denychans::intercept(self, uid, name, is_oper) { + return; + } // an existing channel can refuse the join (+k / +b / +i / +z / +R / +J) if let Some(ch) = self.channels.get(&key) { if let Some(k) = &ch.modes.key { diff --git a/src/modules/denychans.rs b/src/modules/denychans.rs new file mode 100644 index 0000000..e83ff5a --- /dev/null +++ b/src/modules/denychans.rs @@ -0,0 +1,131 @@ +//! denychans — forbid joining channels whose name matches a `badchan` glob, with +//! an optional redirect to a safe channel and an `allowopers` bypass. A `goodchan` +//! glob whitelists names back out of a broad `badchan` pattern. Config: +//! +//! ```text +//! badchan = #evil* reason="That channel is off-limits." redirect=#lobby allowopers=yes +//! goodchan = #evilgenius +//! ``` +//! +//! Dispatched straight from `Server::join` (like the CBAN check), so it works +//! per-channel even when several are joined at once. All config-driven; nothing +//! lives on `Server`. +//! +//! Behaviour reference: InspIRCd's `m_denychans`. Original native Rust. + +use crate::channels::glob_match; +use crate::numeric::{ERR_BADCHANNEL, ERR_LINKCHANNEL}; +use crate::server::Server; +use crate::Uid; + +/// One parsed `badchan` line. +struct BadChan { + glob: String, + reason: String, + redirect: Option, + allowopers: bool, +} + +/// Reuse the quoted-attribute tokenizer shape: split on whitespace but keep +/// `key="quoted value"` together. +fn tokenize(line: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let (mut in_q, mut has) = (false, false); + for ch in line.chars() { + match ch { + '"' => { + in_q = !in_q; + has = true; + } + c if c.is_whitespace() && !in_q => { + if has { + out.push(std::mem::take(&mut cur)); + has = false; + } + } + c => { + cur.push(c); + has = true; + } + } + } + if has { + out.push(cur); + } + out +} + +fn parse(s: &Server) -> Vec { + let mut out = Vec::new(); + for line in s.conf_all("badchan") { + let toks = tokenize(line); + let Some((glob, attrs)) = toks.split_first() else { + continue; + }; + if glob.is_empty() { + continue; + } + let mut bc = BadChan { + glob: glob.clone(), + reason: "Channel is forbidden.".to_string(), + redirect: None, + allowopers: false, + }; + for tok in attrs { + let Some((k, v)) = tok.split_once('=') else { + continue; + }; + match k { + "reason" => bc.reason = v.to_string(), + "redirect" => { + if !v.is_empty() { + bc.redirect = Some(v.to_string()) + } + } + "allowopers" => bc.allowopers = crate::config::yesish(v), + _ => {} + } + } + out.push(bc); + } + out +} + +/// Whether `name` is whitelisted by any `goodchan` glob. +fn is_good(s: &Server, name: &str) -> bool { + s.conf_all("goodchan").iter().any(|g| glob_match(g, name)) +} + +/// Called from `Server::join`. Returns `true` when the join to `name` should be +/// blocked (the caller returns without joining); emits the numeric and performs a +/// redirect join if configured. `is_oper` lets an `allowopers` badchan through. +pub fn intercept(s: &mut Server, uid: Uid, name: &str, is_oper: bool) -> bool { + if s.conf_all("badchan").is_empty() { + return false; + } + if is_good(s, name) { + return false; + } + let bad = parse(s); + let Some(bc) = bad.iter().find(|b| glob_match(&b.glob, name)) else { + return false; + }; + if is_oper && bc.allowopers { + return false; + } + + s.numeric(uid, ERR_BADCHANNEL, &format!("{name} :{}", bc.reason)); + if let Some(redir) = &bc.redirect { + if !redir.eq_ignore_ascii_case(name) { + let redir = redir.clone(); + s.numeric( + uid, + ERR_LINKCHANNEL, + &format!("{name} {redir} :You have been redirected."), + ); + s.join(uid, &redir, None); + } + } + true +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 527fb01..e6700e1 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -11,6 +11,7 @@ pub mod chathistory; pub mod cloak; pub mod connectban; pub mod connflood; +pub mod denychans; pub mod dnsbl; pub mod filter; pub mod flood; @@ -24,6 +25,7 @@ pub mod realnameban; pub mod reputation; pub mod restrictcommands; pub mod restrictmsg; +pub mod securelist; pub mod securitygroups; pub mod serverban; pub mod snoop; @@ -50,6 +52,7 @@ pub fn default_modules() -> Vec> { Box::new(restrictmsg::RestrictMsg), Box::new(blockamsg::BlockAmsg), Box::new(connectban::ConnectBan), + Box::new(securelist::SecureList), ] } diff --git a/src/modules/securelist.rs b/src/modules/securelist.rs new file mode 100644 index 0000000..5be985d --- /dev/null +++ b/src/modules/securelist.rs @@ -0,0 +1,134 @@ +//! securelist — hold back the `/LIST` command until a user has been connected for +//! a while, which defeats spambots that connect, `LIST`, spam every channel and +//! leave. Non-exempt users who `LIST` too early get an optional notice and a +//! throwaway *fake* channel list (so a bot waiting on the reply is satisfied and +//! wastes its time), then the real `LIST` is denied. Exempt: opers, logged-in +//! accounts (when `securelist_exemptregistered`), and hosts matching a +//! `securelist_exception` glob. Off unless `securelist = yes`; all config-driven. +//! +//! Behaviour reference: InspIRCd's `m_securelist`. Original native Rust; the fake +//! names use OpenSSL's CSPRNG (already a dependency) rather than any new crate. + +use openssl::rand::rand_bytes; + +use crate::channels::glob_match; +use crate::module::{ModResult, Module}; +use crate::numeric::{RPL_LIST, RPL_LISTEND, RPL_LISTSTART}; +use crate::server::{now, Server}; +use crate::Uid; + +/// A small unsigned int from the CSPRNG in `0..bound` (bound>0), else 0. +fn rand_below(bound: u32) -> u32 { + if bound == 0 { + return 0; + } + let mut b = [0u8; 4]; + if rand_bytes(&mut b).is_err() { + return 0; + } + u32::from_le_bytes(b) % bound +} + +/// A random lowercase-alnum string of `len` chars (for a fake channel suffix). +fn rand_name(len: usize) -> String { + const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789"; + let mut buf = vec![0u8; len]; + if rand_bytes(&mut buf).is_err() { + return "channel".to_string(); + } + buf.iter() + .map(|&b| ALPHABET[b as usize % ALPHABET.len()] as char) + .collect() +} + +/// Is `uid` exempt from the LIST hold? +fn is_exempt(s: &Server, uid: Uid) -> bool { + if s.is_oper(uid) { + return true; + } + if s.conf_bool("securelist_exemptregistered", true) && s.is_logged_in(uid) { + return true; + } + let exceptions = s.conf_all("securelist_exception"); + if exceptions.is_empty() { + return false; + } + let Some(u) = s.users.get(&uid) else { + return false; + }; + let forms = [ + format!("{}@{}", u.ident, u.host), + format!("{}@{}", u.ident, u.addr.ip()), + ]; + exceptions + .iter() + .any(|mask| forms.iter().any(|f| glob_match(mask, f))) +} + +pub struct SecureList; + +impl Module for SecureList { + fn name(&self) -> &'static str { + "securelist" + } + + fn on_pre_command( + &mut self, + srv: &mut Server, + uid: Uid, + cmd: &str, + _params: &[String], + ) -> ModResult { + if !srv.conf_bool("securelist", false) || !cmd.eq_ignore_ascii_case("LIST") { + return ModResult::Passthru; + } + if is_exempt(srv, uid) { + return ModResult::Passthru; + } + let waittime = srv.conf_num("securelist_waittime", 60u64); + let signon = srv.users.get(&uid).map(|u| u.signon).unwrap_or(0); + let elapsed = now().saturating_sub(signon); + if waittime > 0 && elapsed >= waittime { + return ModResult::Passthru; + } + + // tell them to wait + if srv.conf_bool("securelist_showmsg", true) { + let remain = waittime.saturating_sub(elapsed); + let nick = srv + .users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_default(); + srv.send( + uid, + format!( + ":{} NOTICE {nick} :*** You cannot view the channel list yet. \ + Please try again in {remain} seconds (or log in to an account).", + srv.name + ), + ); + } + + // throwaway fake list so a bot waiting on the reply is satisfied + let fakechans = srv.conf_num("securelist_fakechans", 5u32); + let prefix = srv + .conf("securelist_fakechanprefix") + .unwrap_or("#") + .to_string(); + let topic = srv + .conf("securelist_fakechantopic") + .unwrap_or("Fake channel for confusing spambots") + .to_string(); + let usercount = srv.users.len().max(1) as u32; + + srv.numeric(uid, RPL_LISTSTART, "Channel :Users Name"); + for _ in 0..fakechans { + let suffix = rand_name((rand_below(8) + 3) as usize); + let count = rand_below(usercount) + 1; + srv.numeric(uid, RPL_LIST, &format!("{prefix}{suffix} {count} :{topic}")); + } + srv.numeric(uid, RPL_LISTEND, ":End of channel list."); + ModResult::Deny + } +}