channels/whois: validate +k/+l, cap and case-fold ban lists, honour -n for externals, hide +s/+p/+i from whois/who/names, 301 in whois, conf-key the join/nickflood lockout
This commit is contained in:
parent
4028cd3c84
commit
579c321670
5 changed files with 90 additions and 25 deletions
|
|
@ -1047,10 +1047,18 @@ impl Server {
|
||||||
names.push(' ');
|
names.push(' ');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// visibility symbol: @ secret (+s), * private (+p), = public
|
||||||
|
let vis = if ch.modes.secret {
|
||||||
|
'@'
|
||||||
|
} else if ch.modes.private {
|
||||||
|
'*'
|
||||||
|
} else {
|
||||||
|
'='
|
||||||
|
};
|
||||||
self.numeric(
|
self.numeric(
|
||||||
uid,
|
uid,
|
||||||
RPL_NAMREPLY,
|
RPL_NAMREPLY,
|
||||||
&format!("= {} :{}", ch.name, names.trim_end()),
|
&format!("{vis} {} :{}", ch.name, names.trim_end()),
|
||||||
);
|
);
|
||||||
self.numeric(
|
self.numeric(
|
||||||
uid,
|
uid,
|
||||||
|
|
@ -1179,6 +1187,7 @@ impl Server {
|
||||||
/// +j: record a join attempt on `key`; true if joins are (now) locked out.
|
/// +j: record a join attempt on `key`; true if joins are (now) locked out.
|
||||||
pub fn joinflood_check(&mut self, key: &str) -> bool {
|
pub fn joinflood_check(&mut self, key: &str) -> bool {
|
||||||
let n = now();
|
let n = now();
|
||||||
|
let dur = self.conf_num("joinflood_duration", 60u64);
|
||||||
let Some(ch) = self.channels.get_mut(key) else {
|
let Some(ch) = self.channels.get_mut(key) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
@ -1191,7 +1200,7 @@ impl Server {
|
||||||
ch.joinflood_hits.retain(|&t| n.saturating_sub(t) < f.secs);
|
ch.joinflood_hits.retain(|&t| n.saturating_sub(t) < f.secs);
|
||||||
ch.joinflood_hits.push(n);
|
ch.joinflood_hits.push(n);
|
||||||
if ch.joinflood_hits.len() as u32 > f.count {
|
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();
|
ch.joinflood_hits.clear();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -1202,6 +1211,7 @@ impl Server {
|
||||||
/// is (now) locked out — the caller denies the change if so. Opers exempt.
|
/// is (now) locked out — the caller denies the change if so. Opers exempt.
|
||||||
pub fn nickflood_blocked(&mut self, uid: Uid) -> Option<String> {
|
pub fn nickflood_blocked(&mut self, uid: Uid) -> Option<String> {
|
||||||
let n = now();
|
let n = now();
|
||||||
|
let dur = self.conf_num("nickflood_duration", 60u64);
|
||||||
let keys: Vec<String> = self
|
let keys: Vec<String> = self
|
||||||
.users
|
.users
|
||||||
.get(&uid)
|
.get(&uid)
|
||||||
|
|
@ -1222,7 +1232,7 @@ impl Server {
|
||||||
ch.nickflood_hits.retain(|&t| n.saturating_sub(t) < f.secs);
|
ch.nickflood_hits.retain(|&t| n.saturating_sub(t) < f.secs);
|
||||||
ch.nickflood_hits.push(n);
|
ch.nickflood_hits.push(n);
|
||||||
if ch.nickflood_hits.len() as u32 > f.count {
|
if ch.nickflood_hits.len() as u32 > f.count {
|
||||||
ch.nickflood_until = n + 60;
|
ch.nickflood_until = n + dur;
|
||||||
ch.nickflood_hits.clear();
|
ch.nickflood_hits.clear();
|
||||||
blocked.get_or_insert_with(|| ch.name.clone());
|
blocked.get_or_insert_with(|| ch.name.clone());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -224,13 +224,28 @@ impl Command for Whois {
|
||||||
};
|
};
|
||||||
let chans: Vec<String> = keys
|
let chans: Vec<String> = keys
|
||||||
.iter()
|
.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();
|
.collect();
|
||||||
s.numeric(
|
s.numeric(
|
||||||
uid,
|
uid,
|
||||||
RPL_WHOISUSER,
|
RPL_WHOISUSER,
|
||||||
&format!("{nick} {ident} {disp} * :{realname}"),
|
&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 {
|
if bot {
|
||||||
s.numeric(uid, RPL_WHOISBOT, &format!("{nick} :is a bot"));
|
s.numeric(uid, RPL_WHOISBOT, &format!("{nick} :is a bot"));
|
||||||
}
|
}
|
||||||
|
|
@ -434,7 +449,20 @@ impl Command for Who {
|
||||||
None => Vec::new(),
|
None => Vec::new(),
|
||||||
}
|
}
|
||||||
} else if let Some(tuid) = s.find_nick(target) {
|
} 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 {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -161,20 +161,29 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool)
|
||||||
};
|
};
|
||||||
if target.starts_with('#') {
|
if target.starts_with('#') {
|
||||||
let key = target.to_ascii_lowercase();
|
let key = target.to_ascii_lowercase();
|
||||||
let member = s
|
let (member, no_external) = s
|
||||||
.channels
|
.channels
|
||||||
.get(&key)
|
.get(&key)
|
||||||
.map(|c| c.members.contains_key(&uid))
|
.map(|c| (c.members.contains_key(&uid), c.modes.no_external))
|
||||||
.unwrap_or(false);
|
.unwrap_or((false, true));
|
||||||
if !member {
|
if !member {
|
||||||
if !notice {
|
// +n (default): only members may message the channel. With -n an external
|
||||||
s.numeric(
|
// user may — unless banned (+b, not +e-excepted), so -n can't evade a ban.
|
||||||
uid,
|
let banned = s
|
||||||
ERR_CANNOTSENDTOCHAN,
|
.channels
|
||||||
&format!("{target} :Cannot send to channel"),
|
.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
|
// +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.
|
// to channel ops only (below). It also overrides +m's block for that purpose.
|
||||||
|
|
|
||||||
37
src/mode.rs
37
src/mode.rs
|
|
@ -499,7 +499,9 @@ impl ChanMode for Key {
|
||||||
param: Option<&str>,
|
param: Option<&str>,
|
||||||
) -> Applied {
|
) -> Applied {
|
||||||
if adding {
|
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;
|
return Applied::No;
|
||||||
};
|
};
|
||||||
if let Some(c) = s.channels.get_mut(key) {
|
if let Some(c) = s.channels.get_mut(key) {
|
||||||
|
|
@ -536,7 +538,8 @@ impl ChanMode for Limit {
|
||||||
param: Option<&str>,
|
param: Option<&str>,
|
||||||
) -> Applied {
|
) -> Applied {
|
||||||
if adding {
|
if adding {
|
||||||
let Some(n) = param.and_then(|p| p.parse::<u32>().ok()) else {
|
// reject +l 0 and non-numeric: a zero limit would seal the channel.
|
||||||
|
let Some(n) = param.and_then(|p| p.parse::<u32>().ok()).filter(|&n| n > 0) else {
|
||||||
return Applied::No;
|
return Applied::No;
|
||||||
};
|
};
|
||||||
if let Some(c) = s.channels.get_mut(key) {
|
if let Some(c) = s.channels.get_mut(key) {
|
||||||
|
|
@ -763,17 +766,31 @@ impl ChanMode for ListMode {
|
||||||
.get(&uid)
|
.get(&uid)
|
||||||
.map(|u| u.nick.clone())
|
.map(|u| u.nick.clone())
|
||||||
.unwrap_or_default();
|
.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) {
|
if let Some(c) = s.channels.get_mut(key) {
|
||||||
let list = self.kind.list_mut(c);
|
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
|
return Applied::No; // already present
|
||||||
}
|
}
|
||||||
list.push(Ban {
|
// cap the list for local users; a burst / services set bypasses it.
|
||||||
mask: mask.clone(),
|
if !sudo && list.len() >= maxlist {
|
||||||
setter,
|
full = true;
|
||||||
ts: now(),
|
} else {
|
||||||
expires: None,
|
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))
|
Applied::Yes(Some(mask))
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -781,7 +798,7 @@ impl ChanMode for ListMode {
|
||||||
if let Some(c) = s.channels.get_mut(key) {
|
if let Some(c) = s.channels.get_mut(key) {
|
||||||
let list = self.kind.list_mut(c);
|
let list = self.kind.list_mut(c);
|
||||||
let before = list.len();
|
let before = list.len();
|
||||||
list.retain(|b| b.mask != mask);
|
list.retain(|b| !b.mask.eq_ignore_ascii_case(&mask));
|
||||||
removed = list.len() < before;
|
removed = list.len() < before;
|
||||||
}
|
}
|
||||||
if removed {
|
if removed {
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,7 @@ pub const ERR_CHANNELISFULL: u16 = 471;
|
||||||
pub const ERR_UNKNOWNMODE: u16 = 472;
|
pub const ERR_UNKNOWNMODE: u16 = 472;
|
||||||
pub const ERR_INVITEONLYCHAN: u16 = 473;
|
pub const ERR_INVITEONLYCHAN: u16 = 473;
|
||||||
pub const ERR_BADCHANNELKEY: u16 = 475;
|
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_CHANOPRIVSNEEDED: u16 = 482;
|
||||||
pub const ERR_SECUREONLYCHAN: u16 = 489; // can't join a +z channel without TLS
|
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
|
pub const ERR_ALLMUSTSSL: u16 = 490; // can't set +z while a member isn't on TLS
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue