From ab4d87488b4106cf35b1425a878c7620ebeb0fe8 Mon Sep 17 00:00:00 2001 From: reverse Date: Mon, 10 Aug 2026 08:24:37 +0000 Subject: [PATCH] hidelist: restrict list-mode viewing by rank; allow members to view lists by default (parity) --- echoircd.conf.example | 5 +++++ src/coremods/core_mode.rs | 20 +++++++++++++------- src/mode.rs | 17 +++++++++++++++++ src/modules/hidelist.rs | 37 +++++++++++++++++++++++++++++++++++++ src/modules/mod.rs | 1 + 5 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 src/modules/hidelist.rs diff --git a/echoircd.conf.example b/echoircd.conf.example index 273e7e0..53928b5 100644 --- a/echoircd.conf.example +++ b/echoircd.conf.example @@ -120,6 +120,11 @@ amu_target = both # --- chanlog (m_chanlog): mirror the oper server-notice stream into a channel so # staff can watch it in a normal window. Set the channel (create/keep it opped): # chanlog = #snotices +# --- hidelist (m_hidelist): list modes (+b/+e/+I/…) are viewable by members by +# default; this restricts a given list to a minimum rank. Repeatable, +# `hidelist = ` (rank: owner|admin|op|halfop|voice). Opers see +# everything. e.g. only ops may view the ban list: +# hidelist = b op # --- autoop (m_autoop): no config needed — it's the channel list mode +w. Grant a # status prefix to matching users on join, `+w :`, e.g. # /MODE #chan +w o:*!*@trusted.host (auto-op) diff --git a/src/coremods/core_mode.rs b/src/coremods/core_mode.rs index 6c8fea1..03152b8 100644 --- a/src/coremods/core_mode.rs +++ b/src/coremods/core_mode.rs @@ -88,9 +88,19 @@ pub fn apply_mode(s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { s.numeric(uid, RPL_CREATIONTIME, &format!("{target} {created}")); return CmdResult::Ok; } - // setting modes needs at least half-op; each handler then enforces its - // own finer rule (prefixes need enough rank, +z needs all-secure, …) - if s.rank(uid, &key) < RANK_HALFOP { + // dispatch each mode letter to its handler + let modestring = params[1].clone(); + let args = ¶ms[2..]; + // a *pure list query* (only list-mode letters, no arguments, e.g. `MODE #c +b`) + // is just viewing — allow it for anyone (the hidelist module may still restrict + // it). Anything that changes a mode needs at least half-op; each handler then + // enforces its own finer rule (prefixes need enough rank, +z needs all-secure…). + let pure_list_query = args.is_empty() + && modestring + .chars() + .filter(|c| *c != '+' && *c != '-') + .all(|c| chan_mode(c).is_some_and(|h| h.is_list())); + if !pure_list_query && s.rank(uid, &key) < RANK_HALFOP { s.numeric( uid, ERR_CHANOPRIVSNEEDED, @@ -98,10 +108,6 @@ pub fn apply_mode(s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { ); return CmdResult::Fail; } - - // dispatch each mode letter to its handler - let modestring = params[1].clone(); - let args = ¶ms[2..]; let mut argi = 0usize; let mut sign = '+'; let mut applied = String::new(); diff --git a/src/mode.rs b/src/mode.rs index 2b0fbc2..78bdb35 100644 --- a/src/mode.rs +++ b/src/mode.rs @@ -32,6 +32,11 @@ pub trait ChanMode: Sync { fn letter(&self) -> char; /// Whether to consume an argument for this sign (taken only if one remains). fn wants_param(&self, adding: bool) -> bool; + /// A list mode (+b/+e/+I/+g/+X/+w): a query with no argument is just viewing, + /// so it needn't require channel-operator rank (unlike setting an entry). + fn is_list(&self) -> bool { + false + } /// Apply `+`/`-` to channel `key` (display name `chan`) on behalf of `uid`. fn apply( &self, @@ -571,6 +576,9 @@ impl ChanMode for ListMode { fn letter(&self) -> char { self.ch } + fn is_list(&self) -> bool { + true + } fn wants_param(&self, _adding: bool) -> bool { true // a mask to add/remove; absent ⇒ list query } @@ -586,6 +594,15 @@ impl ChanMode for ListMode { let (entry_num, end_num, noun) = self.kind.numerics(); // no mask ⇒ list query let Some(mask) = param else { + // hidelist: low-rank members may be barred from viewing this list + if crate::modules::hidelist::denied(s, uid, key, self.ch) { + s.numeric( + uid, + ERR_CHANOPRIVSNEEDED, + &format!("{chan} :You do not have access to view the {noun}"), + ); + return Applied::No; + } let rows: Vec<(String, String, u64)> = s .channels .get(key) diff --git a/src/modules/hidelist.rs b/src/modules/hidelist.rs new file mode 100644 index 0000000..03f9771 --- /dev/null +++ b/src/modules/hidelist.rs @@ -0,0 +1,37 @@ +//! hidelist — hide a channel list mode's entries (e.g. the +b ban list) from users +//! below a configured rank, so ordinary members can't enumerate who's banned. +//! Config, repeatable: `hidelist = ` where rank is one of +//! owner|admin|op|halfop|voice. Opers always see. Reference: InspIRCd's +//! `m_hidelist`. Original native Rust. + +use crate::channels::{RANK_ADMIN, RANK_HALFOP, RANK_OP, RANK_OWNER, RANK_VOICE}; +use crate::server::Server; +use crate::Uid; + +fn rank_value(name: &str) -> u8 { + match name.to_ascii_lowercase().as_str() { + "owner" | "founder" | "q" => RANK_OWNER, + "admin" | "protect" | "a" => RANK_ADMIN, + "op" | "o" => RANK_OP, + "halfop" | "h" => RANK_HALFOP, + "voice" | "v" => RANK_VOICE, + _ => RANK_OP, // unknown ⇒ require op, the safe default + } +} + +/// True if `uid` may NOT view the `modechar` list in channel `key`: the config sets +/// a per-mode minimum rank via `hidelist = `. Opers always see. +pub fn denied(s: &Server, uid: Uid, key: &str, modechar: char) -> bool { + if s.is_oper(uid) { + return false; + } + for line in s.conf_all("hidelist") { + let mut it = line.split_whitespace(); + if let (Some(mc), Some(rank)) = (it.next(), it.next()) { + if mc.chars().next() == Some(modechar) { + return s.rank(uid, key) < rank_value(rank); + } + } + } + false +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 64346ae..b94eed4 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -30,6 +30,7 @@ pub mod filter; pub mod flood; pub mod geoip; pub mod hashident; +pub mod hidelist; pub mod hidewhois; pub mod irccloudtags; pub mod jsonlog;