Proof of concept mechanical port of ircnet/ircd to Rust as part of a bit about C being insecure for network services
0

Configure Feed

Select the types of activity you want to include in your feed.

ircd.rs / leveva / src / command / okick.rs
9.8 kB 217 lines
1use crate::command::*; 2use crate::UserMode; 3 4/// `OKICK <channel> <user> [:comment]` — leveva's port of charybdis `extensions/m_okick.c` (P11 5/// slice 294). An IRC **operator** force-kicks `user` from `channel` **without** being a chanop — 6/// or even a member of the channel — sourced from the server so the channel sees `:<server> KICK 7/// <chan> <user> :<comment>`. It is the oper-override sibling of [`KICK`](crate::command::kick) 8/// (chanop-gated) and [`REMOVE`](crate::command::remove) (chanop force-*part*), and the 9/// kick-shaped member of the charybdis operator channel-intervention family 10/// (`SAJOIN`/`SAPART`/`SANICK`/`SAMODE`/`OJOIN`/`OPME`/`REMOVE`). 11/// 12/// Gate order: 13/// - Not an IRC operator (`+o`/`+O`) → `481 ERR_NOPRIVILEGES` (OPME precedent: a plain oper bit, 14/// not a per-command [`OperPrivilege`](crate::config::OperPrivilege); charybdis `m_okick` is 15/// plain `mg_not_oper`). 16/// - Missing channel or user → `461 ERR_NEEDMOREPARAMS`. 17/// - Unknown victim nick → `401 ERR_NOSUCHNICK`. 18/// - Channel does not exist → `403 ERR_NOSUCHCHANNEL`. 19/// - Victim not on the channel → `441 ERR_USERNOTINCHANNEL`. 20/// - Victim holds `+Y` (official-join) → `482 ERR_CHANOPRIVSNEEDED` "official network business" 21/// (a leveva divergence from charybdis's raw removal — the `+Y` immunity stays absolute). 22/// - Success → remove the victim, broadcast `:<server> KICK <chan> <victim> :<comment>` to every 23/// local member (incl. the victim), **echo the KICK back to the oper** (always — the oper need 24/// not be a member; excluded from the audience fan to avoid a duplicate), relay server-sourced 25/// to the network ([`crate::s2s::relay::server_kick`]), and post a `+s` audit snomask. 26/// 27/// Divergences (documented): leveva-native (no oracle — 2.11 has no OKICK); the `+Y` immunity is 28/// respected (charybdis raw-removes); the oper always receives the KICK echo even when not a 29/// member; the audit notice rides the `+s` snomask, not `+w` wallops (SA*/OJOIN/OPME family, 30/// slices 184/186/292); local-only. The comment defaults to the oper's nick (KICK precedent). 31pub(crate) fn okick(client: &Registered, msg: &Message, ctx: &ServerContext) -> Vec<Message> { 32 if client.modes & (UserMode::Oper.bit() | UserMode::LocalOp.bit()) == 0 { 33 return vec![no_privileges(ctx, &client.nick)]; 34 } 35 let Some(chan) = msg.params().first().filter(|s| !s.is_empty()) else { 36 return vec![need_more_params(ctx, &client.nick, "OKICK")]; 37 }; 38 let Some(victim) = msg.params().get(1).filter(|s| !s.is_empty()) else { 39 return vec![need_more_params(ctx, &client.nick, "OKICK")]; 40 }; 41 // Comment is the trailing or a third middle param; defaults to the oper's nick (KICK precedent). 42 let comment = msg 43 .trailing() 44 .or_else(|| msg.params().get(2).map(String::as_str)) 45 .filter(|s| !s.is_empty()) 46 .unwrap_or(&client.nick) 47 .to_string(); 48 49 let Some(uid) = ctx.registry.uid_of(victim) else { 50 return vec![no_such_nick(ctx, &client.nick, victim)]; 51 }; 52 53 match ctx.channels.okick(chan, &uid) { 54 OkickOutcome::NoSuchChannel => vec![no_such_channel(ctx, &client.nick, chan)], 55 OkickOutcome::NotInChannel { display } => vec![Message::builder(Numeric::ErrUsernotinchannel) 56 .prefix(&ctx.name) 57 .param(&client.nick) 58 .param(victim) 59 .param(&display) 60 .trailing("They aren't on that channel") 61 .build()], 62 OkickOutcome::OfficialBusiness { display } => { 63 let victim_nick = ctx 64 .registry 65 .nick_of(&uid) 66 .unwrap_or_else(|| victim.to_string()); 67 vec![Message::builder(Numeric::ErrChanoprivsneeded) 68 .prefix(&ctx.name) 69 .param(&client.nick) 70 .param(&display) 71 .trailing(format!( 72 "Can't kick {victim_nick} as they're on official network business" 73 )) 74 .build()] 75 } 76 OkickOutcome::Kicked { display, audience } => { 77 // The canonical victim nick (resolved live) keeps the broadcast consistent with NAMES. 78 let victim_nick = ctx 79 .registry 80 .nick_of(&uid) 81 .unwrap_or_else(|| victim.to_string()); 82 // Server-sourced KICK (charybdis `:me.name KICK`), echoed to the oper (always — they 83 // may not be a member) and fanned to every other local member incl. the victim. 84 let echo = Message::builder("KICK") 85 .prefix(&ctx.name) 86 .param(&display) 87 .param(&victim_nick) 88 .trailing(&comment) 89 .build(); 90 let wire = echo.to_wire(); 91 for m in &audience { 92 if *m != client.uid && ctx.is_local_uid(m) { 93 ctx.registry.deliver_uid(m, wire.clone()); 94 } 95 } 96 crate::s2s::relay::server_kick(ctx, &display, &uid, &comment); 97 crate::snotice::server_notice( 98 ctx, 99 &format!("{} used OKICK on {victim_nick} in {display}", client.nick), 100 ); 101 vec![echo] 102 } 103 } 104} 105 106#[cfg(test)] 107mod tests { 108 use super::*; 109 use crate::command::testutil::*; 110 111 /// An opered `alice` connection (claimed in the registry) carrying `+o`. 112 fn opered(ctx: &ServerContext) -> Registered { 113 let _ = claim_observed(ctx, "alice"); 114 let mut c = client(); 115 c.modes |= UserMode::Oper.bit(); 116 c 117 } 118 119 fn drive(ctx: &ServerContext, c: &Registered, line: &str) -> Vec<Message> { 120 dispatch(&mut c.clone(), &Message::parse(line).unwrap(), ctx) 121 } 122 123 /// The gate (and its inverse): a non-operator's OKICK is `481` and removes nothing. 124 #[test] 125 fn okick_requires_oper() { 126 let ctx = ctx(); 127 let _bob = claim_observed(&ctx, "bob"); 128 ctx.channels.join("#rust", &uid_for("bob"), 0); // bob the chanop/victim 129 let r = drive(&ctx, &client(), "OKICK #rust bob"); 130 assert_eq!(codes(&r), &["481"]); 131 assert!( 132 ctx.channels.is_member("#rust", &uid_for("bob")), 133 "a gated OKICK removes nothing" 134 ); 135 } 136 137 /// Operand / lookup errors land on the oper. 138 #[test] 139 fn okick_operand_and_lookup_errors() { 140 let ctx = ctx(); 141 let oper = opered(&ctx); 142 assert_eq!(codes(&drive(&ctx, &oper, "OKICK")), &["461"]); 143 assert_eq!(codes(&drive(&ctx, &oper, "OKICK #rust")), &["461"]); 144 // Unknown nick → 401 (checked before the channel exists). 145 assert_eq!(codes(&drive(&ctx, &oper, "OKICK #rust nobody")), &["401"]); 146 // Known nick, ghost channel → 403. 147 let _bob = claim_observed(&ctx, "bob"); 148 assert_eq!(codes(&drive(&ctx, &oper, "OKICK #ghost bob")), &["403"]); 149 // Known nick not on an existing channel → 441. 150 let _ = claim_observed(&ctx, "carol"); 151 ctx.channels.join("#real", &uid_for("carol"), 0); 152 let r = drive(&ctx, &oper, "OKICK #real bob"); 153 assert_eq!(codes(&r), &["441"]); 154 assert_eq!(r[0].params(), &["alice", "bob", "#real"]); 155 } 156 157 /// Success: an oper who is **not a member** force-kicks; the KICK is server-sourced, echoed to 158 /// the oper, seen by a co-member, and the victim is gone. 159 #[test] 160 fn okick_removes_member_as_a_non_member_oper() { 161 let ctx = ctx(); 162 let oper = opered(&ctx); // alice, an oper, never joins #rust 163 let _bob = claim_observed(&ctx, "bob"); 164 let mut carol = claim_observed(&ctx, "carol"); 165 ctx.channels.join("#rust", &uid_for("bob"), 0); // bob the chanop/victim 166 ctx.channels.join("#rust", &uid_for("carol"), 0); // carol a bystander 167 168 let r = drive(&ctx, &oper, "OKICK #rust bob :spam"); 169 // The oper gets the server-sourced KICK echoed back (even though not a member). 170 assert_eq!(codes(&r), &["KICK"]); 171 assert_eq!(r[0].prefix(), Some(ctx.name.as_str())); 172 assert_eq!(r[0].params(), &["#rust", "bob"]); 173 assert_eq!(r[0].trailing(), Some("spam")); 174 // carol witnesses the same KICK. 175 let seen = delivered(&mut carol); 176 assert_eq!(seen.command(), "KICK"); 177 assert_eq!(seen.params(), &["#rust", "bob"]); 178 assert!(!ctx.channels.is_member("#rust", &uid_for("bob")), "bob gone"); 179 assert!(ctx.channels.is_member("#rust", &uid_for("carol"))); 180 } 181 182 /// Default comment is the oper's nick (no reason supplied). 183 #[test] 184 fn okick_default_comment_is_oper_nick() { 185 let ctx = ctx(); 186 let oper = opered(&ctx); 187 let _bob = claim_observed(&ctx, "bob"); 188 ctx.channels.join("#rust", &uid_for("bob"), 0); 189 let r = drive(&ctx, &oper, "OKICK #rust bob"); 190 assert_eq!(r[0].command(), "KICK"); 191 assert_eq!(r[0].trailing(), Some("alice")); 192 } 193 194 /// A `+Y` (official-join) member is un-okick-able → `482`, and nothing changes. 195 #[test] 196 fn okick_refuses_official_join_member() { 197 let ctx = ctx(); 198 let oper = opered(&ctx); 199 let _bob = claim_observed(&ctx, "bob"); 200 ctx.channels.join("#rust", &uid_for("bob"), 0); 201 ctx.channels.apply_modes_as_server( 202 "#rust", 203 &[ModeChange::Member { 204 add: true, 205 mode: ChanMode::OfficialJoin, 206 uid: uid_for("bob"), 207 }], 208 ); 209 let r = drive(&ctx, &oper, "OKICK #rust bob :try"); 210 assert_eq!(codes(&r), &["482"]); 211 assert!(r[0].trailing().unwrap().contains("official network business")); 212 assert!( 213 ctx.channels.is_member("#rust", &uid_for("bob")), 214 "the +Y member stays" 215 ); 216 } 217}