diff --git a/src/channels.rs b/src/channels.rs index cfe42ae..a2101b5 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -1047,10 +1047,18 @@ impl Server { names.push(' '); } } + // visibility symbol: @ secret (+s), * private (+p), = public + let vis = if ch.modes.secret { + '@' + } else if ch.modes.private { + '*' + } else { + '=' + }; self.numeric( uid, RPL_NAMREPLY, - &format!("= {} :{}", ch.name, names.trim_end()), + &format!("{vis} {} :{}", ch.name, names.trim_end()), ); self.numeric( uid, @@ -1179,6 +1187,7 @@ impl Server { /// +j: record a join attempt on `key`; true if joins are (now) locked out. pub fn joinflood_check(&mut self, key: &str) -> bool { let n = now(); + let dur = self.conf_num("joinflood_duration", 60u64); let Some(ch) = self.channels.get_mut(key) else { return false; }; @@ -1191,7 +1200,7 @@ impl Server { ch.joinflood_hits.retain(|&t| n.saturating_sub(t) < f.secs); ch.joinflood_hits.push(n); if ch.joinflood_hits.len() as u32 > f.count { - ch.joinflood_until = n + 60; // lock the channel for 60s + ch.joinflood_until = n + dur; // lock joins for the configured window ch.joinflood_hits.clear(); return true; } @@ -1202,6 +1211,7 @@ impl Server { /// is (now) locked out — the caller denies the change if so. Opers exempt. pub fn nickflood_blocked(&mut self, uid: Uid) -> Option { let n = now(); + let dur = self.conf_num("nickflood_duration", 60u64); let keys: Vec = self .users .get(&uid) @@ -1222,7 +1232,7 @@ impl Server { ch.nickflood_hits.retain(|&t| n.saturating_sub(t) < f.secs); ch.nickflood_hits.push(n); if ch.nickflood_hits.len() as u32 > f.count { - ch.nickflood_until = n + 60; + ch.nickflood_until = n + dur; ch.nickflood_hits.clear(); blocked.get_or_insert_with(|| ch.name.clone()); } diff --git a/src/coremods/core_info.rs b/src/coremods/core_info.rs index 703aee4..4ce88db 100644 --- a/src/coremods/core_info.rs +++ b/src/coremods/core_info.rs @@ -224,13 +224,28 @@ impl Command for Whois { }; let chans: Vec = keys .iter() - .filter_map(|k| s.channels.get(k).map(|c| c.name.clone())) + .filter_map(|k| s.channels.get(k)) + .filter(|c| { + // a +s/+p channel is shown only to the target itself, an oper, or a + // fellow member — never leaked to an outside asker. + is_self + || asker_oper + || (!c.modes.secret && !c.modes.private) + || c.members.contains_key(&uid) + }) + .map(|c| { + let pfx = c.members.get(&tuid).map(|m| m.prefix_char()).unwrap_or(""); + format!("{pfx}{}", c.name) + }) .collect(); s.numeric( uid, RPL_WHOISUSER, &format!("{nick} {ident} {disp} * :{realname}"), ); + if let Some(away) = s.users.get(&tuid).and_then(|u| u.flags.away.clone()) { + s.numeric(uid, RPL_AWAY, &format!("{nick} :{away}")); + } if bot { s.numeric(uid, RPL_WHOISBOT, &format!("{nick} :is a bot")); } @@ -434,7 +449,20 @@ impl Command for Who { None => Vec::new(), } } else if let Some(tuid) = s.find_nick(target) { - vec![(tuid, "*".to_string(), String::new())] + // hide a +i (invisible) user from a WHO by someone who shares no channel + // with them (self and opers always see them). + let hidden = s.users.get(&tuid).map(|u| u.flags.invisible).unwrap_or(false) + && tuid != uid + && !asker_oper + && !s + .channels + .values() + .any(|c| c.members.contains_key(&uid) && c.members.contains_key(&tuid)); + if hidden { + Vec::new() + } else { + vec![(tuid, "*".to_string(), String::new())] + } } else { Vec::new() }; diff --git a/src/coremods/core_message.rs b/src/coremods/core_message.rs index 1674e29..4643d7b 100644 --- a/src/coremods/core_message.rs +++ b/src/coremods/core_message.rs @@ -161,20 +161,29 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) }; if target.starts_with('#') { let key = target.to_ascii_lowercase(); - let member = s + let (member, no_external) = s .channels .get(&key) - .map(|c| c.members.contains_key(&uid)) - .unwrap_or(false); + .map(|c| (c.members.contains_key(&uid), c.modes.no_external)) + .unwrap_or((false, true)); if !member { - if !notice { - s.numeric( - uid, - ERR_CANNOTSENDTOCHAN, - &format!("{target} :Cannot send to channel"), - ); + // +n (default): only members may message the channel. With -n an external + // user may — unless banned (+b, not +e-excepted), so -n can't evade a ban. + let banned = s + .channels + .get(&key) + .map(|c| s.ban_list_hit(uid, &c.bans) && !s.ban_list_hit(uid, &c.excepts)) + .unwrap_or(true); + if no_external || banned { + if !notice { + s.numeric( + uid, + ERR_CANNOTSENDTOCHAN, + &format!("{target} :Cannot send to channel"), + ); + } + return CmdResult::Fail; } - return CmdResult::Fail; } // +U opmoderated — an unprivileged user's message isn't blocked; it's routed // to channel ops only (below). It also overrides +m's block for that purpose. diff --git a/src/mode.rs b/src/mode.rs index 8a551d8..a306683 100644 --- a/src/mode.rs +++ b/src/mode.rs @@ -499,7 +499,9 @@ impl ChanMode for Key { param: Option<&str>, ) -> Applied { if adding { - let Some(k) = param else { + // a key is one non-empty token: reject space/comma/':'/empty, else the + // MODE/FMODE wire line splits and peers parse only the first word. + let Some(k) = param.filter(|k| !k.is_empty() && !k.contains([' ', ',', ':'])) else { return Applied::No; }; if let Some(c) = s.channels.get_mut(key) { @@ -536,7 +538,8 @@ impl ChanMode for Limit { param: Option<&str>, ) -> Applied { if adding { - let Some(n) = param.and_then(|p| p.parse::().ok()) else { + // reject +l 0 and non-numeric: a zero limit would seal the channel. + let Some(n) = param.and_then(|p| p.parse::().ok()).filter(|&n| n > 0) else { return Applied::No; }; if let Some(c) = s.channels.get_mut(key) { @@ -763,17 +766,31 @@ impl ChanMode for ListMode { .get(&uid) .map(|u| u.nick.clone()) .unwrap_or_default(); + let sudo = s.mode_sudo; + let maxlist = s.conf_num("maxbans", 100usize); + let mut full = false; if let Some(c) = s.channels.get_mut(key) { let list = self.kind.list_mut(c); - if list.iter().any(|b| b.mask == mask) { + // dedup case-insensitively: glob_match lowercases at match time, so + // `*!*@Host` and `*!*@host` catch the same users — store only one. + if list.iter().any(|b| b.mask.eq_ignore_ascii_case(&mask)) { return Applied::No; // already present } - list.push(Ban { - mask: mask.clone(), - setter, - ts: now(), - expires: None, - }); + // cap the list for local users; a burst / services set bypasses it. + if !sudo && list.len() >= maxlist { + full = true; + } else { + list.push(Ban { + mask: mask.clone(), + setter, + ts: now(), + expires: None, + }); + } + } + if full { + s.numeric(uid, ERR_BANLISTFULL, &format!("{chan} {mask} :Channel list is full")); + return Applied::No; } Applied::Yes(Some(mask)) } else { @@ -781,7 +798,7 @@ impl ChanMode for ListMode { if let Some(c) = s.channels.get_mut(key) { let list = self.kind.list_mut(c); let before = list.len(); - list.retain(|b| b.mask != mask); + list.retain(|b| !b.mask.eq_ignore_ascii_case(&mask)); removed = list.len() < before; } if removed { diff --git a/src/numeric.rs b/src/numeric.rs index ddd22d4..5a5eccc 100644 --- a/src/numeric.rs +++ b/src/numeric.rs @@ -139,6 +139,7 @@ pub const ERR_CHANNELISFULL: u16 = 471; pub const ERR_UNKNOWNMODE: u16 = 472; pub const ERR_INVITEONLYCHAN: u16 = 473; pub const ERR_BADCHANNELKEY: u16 = 475; +pub const ERR_BANLISTFULL: u16 = 478; // +b/+e/+I list at its per-channel cap pub const ERR_CHANOPRIVSNEEDED: u16 = 482; pub const ERR_SECUREONLYCHAN: u16 = 489; // can't join a +z channel without TLS pub const ERR_ALLMUSTSSL: u16 = 490; // can't set +z while a member isn't on TLS