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).
This commit is contained in:
Jean Chevronnet 2026-08-29 02:17:32 +00:00
parent bc412c218e
commit e2a64b7806
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
3 changed files with 137 additions and 8 deletions

View file

@ -92,12 +92,19 @@ oper {
# #
# A class also grants privileges — named permissions checked at sensitive points. # A class also grants privileges — named permissions checked at sensitive points.
# "privs=*" grants all. Standard privileges: # "privs=*" grants all. Standard privileges:
# users/auspex see a user's real host+IP and geo (WHOIS/WHO + the connect # users/auspex real host+IP and geo (WHOIS/WHO + connect notice), +i users
# notice), and see +i users who share no channel with you # channels/auspex secret/private (+s/+p) channels in LIST/WHO/WHOIS
# channels/auspex see secret/private (+s/+p) channels in LIST/WHO/WHOIS # servers/auspex services (U-lined) servers hidden by hideservices
# Only netadmin holds all privileges by default; grant the built-in "auspex" class to # channels/override join through +k/+b/+i/+l/+z/+R/+J, CBAN and the max-chans cap
# any other type that should see through privacy. # users/flood exempt from message- and join-flood limits
# class { name "helpdesk"; commands "CHECK"; snomasks "c"; privs "users/auspex"; } # 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; } # opertype { name "helpdesk"; classes "helpdesk auspex"; modes "+ih"; title "Help_Desk"; level 15; }

View file

@ -433,6 +433,15 @@ impl ChanMode for OperFlagChan {
); );
return Applied::No; 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) { if let Some(c) = s.channels.get_mut(key) {
(self.set)(&mut c.modes, adding); (self.set)(&mut c.modes, adding);
} }
@ -1362,6 +1371,15 @@ impl UserMode for OperFlag {
); );
return false; 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) { if let Some(u) = s.users.get_mut(&uid) {
(self.set)(&mut u.flags, adding); (self.set)(&mut u.flags, adding);
true true

View file

@ -47,6 +47,8 @@ pub struct OperType {
pub commands: HashSet<String>, pub commands: HashSet<String>,
pub all_privs: bool, pub all_privs: bool,
pub privs: HashSet<String>, pub privs: HashSet<String>,
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, /// 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<char>),
}
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::<OperType>()) {
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<ModeAllow>, add: &Option<ModeAllow>) {
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 /// 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 /// store the grant + title. A missing type (or an unknown id) leaves the oper with
/// full access, so `oper` blocks without `type=` keep working. /// 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(), commands: r.commands.clone(),
all_privs: r.all_privs, all_privs: r.all_privs,
privs: r.privs.clone(), privs: r.privs.clone(),
usermodes: r.usermodes.clone(),
chanmodes: r.chanmodes.clone(),
}); });
} }
} }
@ -193,6 +254,8 @@ struct Resolved {
all_snomasks: bool, all_snomasks: bool,
vhost: Option<String>, vhost: Option<String>,
level: Option<u32>, level: Option<u32>,
usermodes: ModeAllow,
chanmodes: ModeAllow,
} }
thread_local! { thread_local! {
@ -218,6 +281,8 @@ struct ClassDef {
privs: Vec<String>, privs: Vec<String>,
all_snomasks: bool, all_snomasks: bool,
snomasks: String, snomasks: String,
usermodes: Option<ModeAllow>,
chanmodes: Option<ModeAllow>,
} }
#[derive(Default, Clone)] #[derive(Default, Clone)]
@ -235,6 +300,8 @@ struct TypeDef {
vhost: Option<String>, vhost: Option<String>,
level: Option<u32>, level: Option<u32>,
color: Option<u8>, color: Option<u8>,
usermodes: Option<ModeAllow>,
chanmodes: Option<ModeAllow>,
} }
fn cdef(commands: &[&str], privs: &[&str], sno: &str) -> ClassDef { 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); 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())); 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" => { "snomasks" | "snomask" => {
if v.contains('*') { if v.contains('*') {
td.all_snomasks = true; td.all_snomasks = true;
@ -410,6 +481,14 @@ fn resolve(td: &TypeDef, classes: &HashMap<String, ClassDef>) -> Resolved {
let mut privs: HashSet<String> = td.privs.iter().cloned().collect(); let mut privs: HashSet<String> = td.privs.iter().cloned().collect();
let mut all_sno = td.all_snomasks; let mut all_sno = td.all_snomasks;
let mut sno = td.snomasks.clone(); 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<ModeAllow> = td.usermodes.clone();
let mut chanmodes: Option<ModeAllow> = td.chanmodes.clone();
if td.all_classes {
usermodes = Some(ModeAllow::All);
chanmodes = Some(ModeAllow::All);
}
let names: Vec<String> = if td.all_classes { let names: Vec<String> = if td.all_classes {
classes.keys().cloned().collect() classes.keys().cloned().collect()
@ -424,6 +503,8 @@ fn resolve(td: &TypeDef, classes: &HashMap<String, ClassDef>) -> Resolved {
privs.extend(cd.privs.iter().cloned()); privs.extend(cd.privs.iter().cloned());
all_sno |= cd.all_snomasks; all_sno |= cd.all_snomasks;
sno.push_str(&cd.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<String, ClassDef>) -> Resolved {
all_snomasks: all_sno, all_snomasks: all_sno,
vhost: td.vhost.clone(), vhost: td.vhost.clone(),
level: td.level, 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<String, ClassDef> = 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] #[test]
fn gated_covers_the_dangerous_commands_only() { fn gated_covers_the_dangerous_commands_only() {
assert!(gated("kill") && gated("DIE") && gated("svsnick") && gated("CONNECT")); assert!(gated("kill") && gated("DIE") && gated("svsnick") && gated("CONNECT"));