From e2a64b780684b99b094f61c0152e6479926d75c2 Mon Sep 17 00:00:00 2001 From: reverse Date: Sat, 29 Aug 2026 02:17:32 +0000 Subject: [PATCH] opers: enforce the per-type usermode/chanmode allowlist The usermodes=/chanmodes= class/type keys were parsed then ignored. Add a ModeAllow allowlist to the resolved oper type + can_use_mode(), checked in the oper-only mode handlers (services under mode_sudo pass through). Unspecified stays permissive so built-in types are unrestricted; a type opts into restriction with an explicit letter list (or * = all). --- echoircd.conf.example | 19 ++++--- src/mode.rs | 18 +++++++ src/modules/opertypes.rs | 108 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 137 insertions(+), 8 deletions(-) diff --git a/echoircd.conf.example b/echoircd.conf.example index 73a5a98..bfd84f2 100644 --- a/echoircd.conf.example +++ b/echoircd.conf.example @@ -92,12 +92,19 @@ oper { # # A class also grants privileges — named permissions checked at sensitive points. # "privs=*" grants all. Standard privileges: -# users/auspex see a user's real host+IP and geo (WHOIS/WHO + the connect -# notice), and see +i users who share no channel with you -# channels/auspex see secret/private (+s/+p) channels in LIST/WHO/WHOIS -# Only netadmin holds all privileges by default; grant the built-in "auspex" class to -# any other type that should see through privacy. -# class { name "helpdesk"; commands "CHECK"; snomasks "c"; privs "users/auspex"; } +# users/auspex real host+IP and geo (WHOIS/WHO + connect notice), +i users +# channels/auspex secret/private (+s/+p) channels in LIST/WHO/WHOIS +# servers/auspex services (U-lined) servers hidden by hideservices +# channels/override join through +k/+b/+i/+l/+z/+R/+J, CBAN and the max-chans cap +# users/flood exempt from message- and join-flood limits +# users/ignore-commonchans PM a +c user without sharing a common channel +# Only netadmin holds every privilege by default. Built-in "auspex" grants the auspex +# trio; "override" grants channels/override + users/flood. +# +# usermodes="…"/chanmodes="…" restrict which oper-only modes the type may SET (a letter +# list, or "*" for all). Unspecified = all, so built-ins are unrestricted. ("modes" is +# separate: the usermodes auto-applied at oper-up.) +# class { name "helpdesk"; commands "CHECK"; snomasks "c"; privs "users/auspex"; usermodes "s"; } # opertype { name "helpdesk"; classes "helpdesk auspex"; modes "+ih"; title "Help_Desk"; level 15; } diff --git a/src/mode.rs b/src/mode.rs index 78c1650..06617b1 100644 --- a/src/mode.rs +++ b/src/mode.rs @@ -433,6 +433,15 @@ impl ChanMode for OperFlagChan { ); return Applied::No; } + // per-oper-type chanmode allowlist (services under sudo pass through) + if adding && !s.mode_sudo && !crate::modules::opertypes::can_use_mode(s, uid, self.ch, true) { + s.numeric( + uid, + ERR_NOPRIVILEGES, + &format!("{chan} :Your oper type may not set channel mode +{}", self.ch), + ); + return Applied::No; + } if let Some(c) = s.channels.get_mut(key) { (self.set)(&mut c.modes, adding); } @@ -1362,6 +1371,15 @@ impl UserMode for OperFlag { ); return false; } + // per-oper-type usermode allowlist (services under sudo pass through) + if adding && !s.mode_sudo && !crate::modules::opertypes::can_use_mode(s, uid, self.ch, false) { + s.numeric( + uid, + ERR_NOPRIVILEGES, + ":Permission Denied- your oper type may not set that user mode", + ); + return false; + } if let Some(u) = s.users.get_mut(&uid) { (self.set)(&mut u.flags, adding); true diff --git a/src/modules/opertypes.rs b/src/modules/opertypes.rs index 45e2164..3c1ff56 100644 --- a/src/modules/opertypes.rs +++ b/src/modules/opertypes.rs @@ -47,6 +47,8 @@ pub struct OperType { pub commands: HashSet, pub all_privs: bool, pub privs: HashSet, + pub usermodes: ModeAllow, // oper-only user modes this type may set + pub chanmodes: ModeAllow, // oper-only channel modes this type may set } /// Commands an oper type gates. Anything outside this set (OPERMOTD, MKPASSWD, @@ -132,6 +134,63 @@ pub fn user_has_priv(u: &crate::users::User, name: &str) -> bool { } } +/// Which oper-only modes a type may set: `All` (`usermodes="*"`, or a type that never +/// restricts) or `Only(set)` for an explicit letter list. An unspecified allowlist +/// resolves to `All`, so a type restricts modes only when it opts in. +#[derive(Clone, Default)] +pub enum ModeAllow { + #[default] + All, + Only(HashSet), +} + +impl ModeAllow { + fn allows(&self, c: char) -> bool { + match self { + ModeAllow::All => true, + ModeAllow::Only(set) => set.contains(&c), + } + } +} + +/// Whether oper `uid`'s type may set the oper-only mode `letter` (`chan` picks the +/// channel-mode vs user-mode allowlist). Legacy untyped opers may set anything; the +/// caller has already confirmed oper-ness, so this only applies the per-type allowlist. +pub fn can_use_mode(s: &Server, uid: Uid, letter: char, chan: bool) -> bool { + match s.users.get(&uid).and_then(|u| u.ext.get::()) { + None => true, + Some(t) => { + if chan { + t.chanmodes.allows(letter) + } else { + t.usermodes.allows(letter) + } + } + } +} + +/// Parse a `usermodes=`/`chanmodes=` value into an allowlist (`*` = all). +fn parse_modeallow(v: &str) -> ModeAllow { + if v.contains('*') { + ModeAllow::All + } else { + ModeAllow::Only(v.chars().filter(|c| c.is_ascii_alphabetic()).collect()) + } +} + +/// Fold `add` into `acc`, favouring the more permissive result (All wins, else union). +fn merge_modeallow(acc: &mut Option, add: &Option) { + match add { + None => {} + Some(ModeAllow::All) => *acc = Some(ModeAllow::All), + Some(ModeAllow::Only(s)) => match acc { + Some(ModeAllow::All) => {} + Some(ModeAllow::Only(existing)) => existing.extend(s.iter().copied()), + None => *acc = Some(ModeAllow::Only(s.clone())), + }, + } +} + /// Apply the oper's type at oper-up: auto usermodes / snomasks / vhost / level, then /// store the grant + title. A missing type (or an unknown id) leaves the oper with /// full access, so `oper` blocks without `type=` keep working. @@ -166,6 +225,8 @@ pub fn apply(s: &mut Server, uid: Uid, type_id: Option<&str>) { commands: r.commands.clone(), all_privs: r.all_privs, privs: r.privs.clone(), + usermodes: r.usermodes.clone(), + chanmodes: r.chanmodes.clone(), }); } } @@ -193,6 +254,8 @@ struct Resolved { all_snomasks: bool, vhost: Option, level: Option, + usermodes: ModeAllow, + chanmodes: ModeAllow, } thread_local! { @@ -218,6 +281,8 @@ struct ClassDef { privs: Vec, all_snomasks: bool, snomasks: String, + usermodes: Option, + chanmodes: Option, } #[derive(Default, Clone)] @@ -235,6 +300,8 @@ struct TypeDef { vhost: Option, level: Option, color: Option, + usermodes: Option, + chanmodes: Option, } fn cdef(commands: &[&str], privs: &[&str], sno: &str) -> ClassDef { @@ -339,7 +406,9 @@ fn apply_class_kv(cd: &mut ClassDef, k: &str, v: &str) { cd.snomasks.push_str(v); } } - _ => {} // usermodes/chanmodes allowlist: accepted but not yet enforced + "usermodes" => cd.usermodes = Some(parse_modeallow(v)), + "chanmodes" => cd.chanmodes = Some(parse_modeallow(v)), + _ => {} } } @@ -366,7 +435,9 @@ fn apply_type_kv(td: &mut TypeDef, k: &str, v: &str) { td.privs.extend(v.split(',').filter(|x| !x.is_empty()).map(|x| x.to_ascii_lowercase())); } } - "modes" | "usermodes" => td.modes = v.to_string(), + "modes" => td.modes = v.to_string(), + "usermodes" => td.usermodes = Some(parse_modeallow(v)), + "chanmodes" => td.chanmodes = Some(parse_modeallow(v)), "snomasks" | "snomask" => { if v.contains('*') { td.all_snomasks = true; @@ -410,6 +481,14 @@ fn resolve(td: &TypeDef, classes: &HashMap) -> Resolved { let mut privs: HashSet = td.privs.iter().cloned().collect(); let mut all_sno = td.all_snomasks; let mut sno = td.snomasks.clone(); + // mode allowlists: an all-classes type may set every oper mode; otherwise merge the + // classes' + type's lists, and an unspecified allowlist stays permissive (`All`). + let mut usermodes: Option = td.usermodes.clone(); + let mut chanmodes: Option = td.chanmodes.clone(); + if td.all_classes { + usermodes = Some(ModeAllow::All); + chanmodes = Some(ModeAllow::All); + } let names: Vec = if td.all_classes { classes.keys().cloned().collect() @@ -424,6 +503,8 @@ fn resolve(td: &TypeDef, classes: &HashMap) -> Resolved { privs.extend(cd.privs.iter().cloned()); all_sno |= cd.all_snomasks; sno.push_str(&cd.snomasks); + merge_modeallow(&mut usermodes, &cd.usermodes); + merge_modeallow(&mut chanmodes, &cd.chanmodes); } } @@ -451,6 +532,8 @@ fn resolve(td: &TypeDef, classes: &HashMap) -> Resolved { all_snomasks: all_sno, vhost: td.vhost.clone(), level: td.level, + usermodes: usermodes.unwrap_or_default(), + chanmodes: chanmodes.unwrap_or_default(), } } @@ -517,6 +600,27 @@ mod tests { ); } + #[test] + fn mode_allowlist_defaults_permissive_and_restricts_when_set() { + let no_classes: HashMap = HashMap::default(); + // built-ins never restrict modes → every oper-only letter is allowed + for id in ["helpop", "globop", "admin", "servadmin", "netadmin"] { + let r = resolved(id); + assert!(r.usermodes.allows('H') && r.chanmodes.allows('O'), "{id} unrestricted"); + } + // an explicit list restricts to those letters; the unset axis stays permissive + let mut td = TypeDef::default(); + apply_type_kv(&mut td, "usermodes", "iw"); + let r = resolve(&td, &no_classes); + assert!(r.usermodes.allows('i') && r.usermodes.allows('w')); + assert!(!r.usermodes.allows('H'), "H is not in the usermodes allowlist"); + assert!(r.chanmodes.allows('O'), "unspecified chanmodes stay permissive"); + // "*" grants all + let mut td2 = TypeDef::default(); + apply_type_kv(&mut td2, "usermodes", "*"); + assert!(resolve(&td2, &no_classes).usermodes.allows('H')); + } + #[test] fn gated_covers_the_dangerous_commands_only() { assert!(gated("kill") && gated("DIE") && gated("svsnick") && gated("CONNECT"));