From 77d09c4bdc16924766f313bc0563d7dbd54db36c Mon Sep 17 00:00:00 2001 From: Jean Date: Fri, 17 Jul 2026 22:01:32 +0000 Subject: [PATCH] akick: accept a matching extban only the ircd knows (ircd-enforced +b), reject acting extbans with a clear reason --- api/src/lib.rs | 24 ++++++++++++++ modules/chanserv/src/akick.rs | 56 +++++++++++++++++++++------------ modules/chanserv/src/enforce.rs | 2 +- src/engine/db/account.rs | 11 +++++++ src/engine/db/store.rs | 3 ++ 5 files changed, 75 insertions(+), 21 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index 54baabb..8fca025 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -1136,6 +1136,18 @@ impl<'a> AkickMask<'a> { } } +/// Whether echo leans on the ircd to enforce an akick with this `mask` (by setting +/// a channel `+b`) because it can't evaluate the mask itself: a passive extban, or +/// a matching extban the ircd offers but echo's static table doesn't know. Host +/// masks and echo's own matchable extbans return false — echo kicks those on join. +pub fn ircd_enforced(mask: &str) -> bool { + match AkickMask::parse(mask) { + AkickMask::Host(_) => false, + AkickMask::Ext(eb, _) => !eb.matchable(), + AkickMask::Unknown(_) => true, + } +} + /// Whether an AKICK `mask` matches a live user `t` — a plain hostmask (globbed /// against the displayed-host, real-host and IP forms, as InspIRCd's CheckBan /// does) or a matching-extban echo has the data for. Passive/unknown never match. @@ -1560,6 +1572,11 @@ pub trait Store { fn extban_offered(&self, _name: &str) -> bool { true } + // Resolve an extban token (name or single letter) the ircd advertised but echo's + // static table doesn't know, to its live-registry entry. None if not offered. + fn extban_lookup(&self, _token: &str) -> Option { + None + } fn exists(&self, name: &str) -> bool; fn account(&self, name: &str) -> Option; // Canonical account name for a nick (following a grouping), if registered. @@ -2035,6 +2052,13 @@ mod tests { let anon = BanTarget { account: None, ..t }; assert!(akick_matches("unauthed:*!*@h.com", &anon), "not logged in + host matches"); assert!(!akick_matches("account:bad", &anon), "no account -> account extban can't match"); + + // ircd_enforced: host masks and echo's matchable extbans are echo's job; + // passive extbans and ones echo's table doesn't know are the ircd's (a +b). + assert!(!ircd_enforced("*!*@host"), "host mask: echo kicks"); + assert!(!ircd_enforced("account:bad"), "matchable extban: echo kicks"); + assert!(ircd_enforced("country:US"), "passive extban: ircd enforces"); + assert!(ircd_enforced("mute:*!*@x"), "extban echo's table lacks: ircd enforces"); } #[test] diff --git a/modules/chanserv/src/akick.rs b/modules/chanserv/src/akick.rs index 417ddec..f1a7ffe 100644 --- a/modules/chanserv/src/akick.rs +++ b/modules/chanserv/src/akick.rs @@ -28,22 +28,38 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: ctx.notice(me, from.uid, "Syntax: AKICK <#channel> ADD [reason]"); return; }; - // Reject something that's neither a host mask nor a known extban, and - // any extban the network's `[extban] enabled` config has turned off. + // Validate the mask: a host mask always passes; an extban must be one + // this ircd offers, be a matching (not acting) kind, and be allowed by + // the `[extban] enabled` config. Extbans echo's own table doesn't know + // are resolved against the ircd's live set so a new matching extban + // still works (the ircd enforces it). match echo_api::AkickMask::parse(mask) { - echo_api::AkickMask::Unknown(name) => { - ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't a host mask or a known extban.")); - return; + echo_api::AkickMask::Host(_) => {} + echo_api::AkickMask::Ext(eb, _) => { + if !db.extban_offered(eb.name) { + ctx.notice(me, from.uid, format!("This network's ircd doesn't offer the \x02{}\x02 extban.", eb.name)); + return; + } + if !db.extban_enabled(eb.name) { + ctx.notice(me, from.uid, format!("The \x02{}\x02 extban isn't enabled on this network.", eb.name)); + return; + } } - echo_api::AkickMask::Ext(eb, _) if !db.extban_offered(eb.name) => { - ctx.notice(me, from.uid, format!("This network's ircd doesn't offer the \x02{}\x02 extban.", eb.name)); - return; - } - echo_api::AkickMask::Ext(eb, _) if !db.extban_enabled(eb.name) => { - ctx.notice(me, from.uid, format!("The \x02{}\x02 extban isn't enabled on this network.", eb.name)); - return; - } - _ => {} + echo_api::AkickMask::Unknown(token) => match db.extban_lookup(token) { + None => { + ctx.notice(me, from.uid, format!("\x02{token}\x02 isn't a host mask or an extban this network offers.")); + return; + } + Some(cap) if cap.acting => { + ctx.notice(me, from.uid, format!("\x02{}\x02 is an acting extban; auto-kick needs a matching extban or a host mask.", cap.name)); + return; + } + Some(cap) if !db.extban_enabled(&cap.name) => { + ctx.notice(me, from.uid, format!("The \x02{}\x02 extban isn't enabled on this network.", cap.name)); + return; + } + Some(_) => {} + }, } if !super::require_op(me, from, chan, ctx, db) { return; @@ -51,10 +67,10 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: let reason = if args.len() > 4 { args[4..].join(" ") } else { "Auto-kicked".to_string() }; match db.akick_add(chan, mask, &reason) { Ok(()) => { - // Extbans echo can't evaluate itself (country, asn, …) are pushed - // to the channel as a ban so the ircd enforces them; the ones echo - // matches are applied on join / ENFORCE and need no standing mode. - if matches!(echo_api::AkickMask::parse(mask), echo_api::AkickMask::Ext(eb, _) if !eb.matchable()) { + // Extbans echo can't evaluate itself are pushed to the channel as + // a ban so the ircd enforces them; the ones echo matches are applied + // on join / ENFORCE and need no standing mode. + if echo_api::ircd_enforced(mask) { ctx.channel_mode(me, chan, &format!("+b {mask}")); } ctx.notice(me, from.uid, format!("Added \x02{mask}\x02 to \x02{chan}\x02's auto-kick list.")); @@ -73,7 +89,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: match db.akick_del(chan, mask) { Ok(true) => { // Lift the standing ban we set for an ircd-enforced extban. - if matches!(echo_api::AkickMask::parse(mask), echo_api::AkickMask::Ext(eb, _) if !eb.matchable()) { + if echo_api::ircd_enforced(mask) { ctx.channel_mode(me, chan, &format!("-b {mask}")); } ctx.notice(me, from.uid, format!("Removed \x02{mask}\x02 from \x02{chan}\x02's auto-kick list.")); @@ -90,7 +106,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: let masks: Vec = db.channel(chan).map_or_else(Vec::new, |info| info.akick.iter().map(|k| k.mask.clone()).collect()); for mask in &masks { let _ = db.akick_del(chan, mask); - if matches!(echo_api::AkickMask::parse(mask), echo_api::AkickMask::Ext(eb, _) if !eb.matchable()) { + if echo_api::ircd_enforced(mask) { ctx.channel_mode(me, chan, &format!("-b {mask}")); } } diff --git a/modules/chanserv/src/enforce.rs b/modules/chanserv/src/enforce.rs index 97ff800..3c2e9c7 100644 --- a/modules/chanserv/src/enforce.rs +++ b/modules/chanserv/src/enforce.rs @@ -18,7 +18,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: ctx.channel_mode(me, chan, &info.lock_modes()); // Re-assert the bans for extbans the ircd enforces (echo can't match them itself). for k in &info.akick { - if matches!(echo_api::AkickMask::parse(&k.mask), echo_api::AkickMask::Ext(eb, _) if !eb.matchable()) { + if echo_api::ircd_enforced(&k.mask) { ctx.channel_mode(me, chan, &format!("+b {}", k.mask)); } } diff --git a/src/engine/db/account.rs b/src/engine/db/account.rs index 14a788a..8853f3f 100644 --- a/src/engine/db/account.rs +++ b/src/engine/db/account.rs @@ -187,6 +187,17 @@ impl Db { self.live_extbans.as_ref().is_none_or(|set| set.iter().any(|e| e.name.eq_ignore_ascii_case(name))) } + /// Resolve an extban token (name, case-insensitive; or single letter, case- + /// sensitive) against the ircd's live set. For extbans echo's static table lacks. + pub fn extban_lookup(&self, token: &str) -> Option { + let live = self.live_extbans.as_ref()?; + let mut chars = token.chars(); + match (chars.next(), chars.next()) { + (Some(c), None) => live.iter().find(|e| e.letter == Some(c)).cloned(), + _ => live.iter().find(|e| e.name.eq_ignore_ascii_case(token)).cloned(), + } + } + /// Display name used in email templates. pub fn email_brand(&self) -> &str { &self.email_brand diff --git a/src/engine/db/store.rs b/src/engine/db/store.rs index 39d9faf..7e8d852 100644 --- a/src/engine/db/store.rs +++ b/src/engine/db/store.rs @@ -60,6 +60,9 @@ impl Store for Db { fn extban_offered(&self, name: &str) -> bool { Db::extban_offered(self, name) } + fn extban_lookup(&self, token: &str) -> Option { + Db::extban_lookup(self, token) + } fn email_enabled(&self) -> bool { Db::email_enabled(self) }