From 65323caaeb37f4bca36dbd3e815118a34bb8b576 Mon Sep 17 00:00:00 2001 From: Jean Date: Fri, 17 Jul 2026 18:14:58 +0000 Subject: [PATCH] chanserv: FLAGS maps sop/halfop presets to flag letters (oa=+ao), fixing privilege escalation when editing a HOP/SOP entry --- api/src/lib.rs | 13 ++++++++++++- modules/chanserv/src/flags.rs | 11 ++++++++--- src/engine/tests.rs | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index bee3510..6cd04e8 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -676,8 +676,10 @@ pub fn level_caps(level: &str) -> Caps { "voice" => Caps { auto: Some("+v"), rank: Rank::Voice, ..Caps::default() }, flags => { let has = |c: char| flags.contains(c); + // Auto-op plus access-list management is the SOP tier — it auto-gets + // protect + op (+ao), so a flag entry "oa" matches the "sop" preset. let auto = if has('o') { - Some("+o") + if has('a') { Some("+ao") } else { Some("+o") } } else if has('h') { Some("+h") } else if has('v') { @@ -1725,6 +1727,15 @@ mod tests { // and founder (+qo) over everything. assert_eq!(level_caps("sop").union(level_caps("voice")).auto, Some("+ao")); assert_eq!(level_caps("founder").union(level_caps("sop")).auto, Some("+qo")); + + // The FLAGS display maps presets to flag letters that round-trip: the flag + // string resolves to the very same caps (op+access "oa" = the SOP tier, + // auto +ao), so FLAGS-editing a preset entry can't corrupt it. + for (preset, flags) in [("sop", "otia"), ("op", "oti"), ("halfop", "h"), ("voice", "v")] { + assert_eq!(level_caps(flags).auto, level_caps(preset).auto, "{preset} auto"); + assert_eq!(level_caps(flags).rank, level_caps(preset).rank, "{preset} rank"); + } + assert_eq!(level_caps("oa").auto, Some("+ao"), "op + access = protected admin"); } #[test] diff --git a/modules/chanserv/src/flags.rs b/modules/chanserv/src/flags.rs index 7d5c0ef..0b47493 100644 --- a/modules/chanserv/src/flags.rs +++ b/modules/chanserv/src/flags.rs @@ -69,12 +69,17 @@ pub fn handle(me: &str, from: &Sender, chan: &str, args: &[&str], ctx: &mut Serv } } -// Show a stored level as flag letters (mapping the legacy presets). +// Show a stored level as flag letters, mapping the tier presets to their +// equivalent flags so FLAGS can round-trip and edit them. Each mapping resolves +// to the same capabilities as the preset (see `level_caps`): sop = op + topic + +// invite + access (auto +ao), op = op + topic + invite, halfop = auto +h. fn display_flags(level: &str) -> String { match level { - "op" => "oti".to_string(), - "voice" => "v".to_string(), "founder" => "f".to_string(), + "sop" => "otia".to_string(), + "op" => "oti".to_string(), + "halfop" => "h".to_string(), + "voice" => "v".to_string(), flags => flags.to_string(), } } diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 764bf94..9e4589c 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -1459,6 +1459,38 @@ assert!(kicked(&out) && !blocked(&out), "SOP can kick an AOP: {out:?}"); } + // Regression: FLAGS-editing a HOP/SOP entry must not corrupt it. Adding +v to a + // halfop once escalated them to founder — the preset name "halfop" got reparsed + // as flags (f,o,h,a — 'f' = founder). It must stay in the halfop tier. + #[test] + fn flags_edit_of_a_preset_entry_does_not_escalate() { + use echo_chanserv::ChanServ; + use echo_nickserv::NickServ; + let path = std::env::temp_dir().join("echo-flagsesc.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.scram_iterations = 4096; + db.register("alice", "sesame", None).unwrap(); + db.register("bob", "sesame", None).unwrap(); + db.register_channel("#c", "alice").unwrap(); + db.access_add("#c", "bob", "halfop").unwrap(); + let mut e = Engine::new( + vec![ + Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }), + Box::new(ChanServ { uid: "42SAAAAAB".into() }), + ], + db, + ); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAB".into(), text: "FLAGS #c bob +v".into() }); + + let ci = e.db.channel("#c").unwrap(); + assert!(!ci.is_op("bob"), "halfop + voice must not gain op or founder"); + let lvl = ci.access.iter().find(|a| a.account == "bob").unwrap().level.clone(); + assert!(!lvl.contains('f'), "must not acquire the founder flag: got {lvl:?}"); + } + // NickServ LIST is auspex-gated and glob-matches; UPDATE refreshes a session. #[test] fn nickserv_list_and_update() {