OperServ: CBAN — ban a channel name

CBAN ADD <#channel-glob> <reason> / DEL / LIST blocks users from creating
or joining a matching channel (m_cban is loaded). Another kind ("CBAN")
in the generalized X-line handler with a channel-mask normalizer; the
too-wide guard now ignores a leading channel prefix so #* is refused.
This commit is contained in:
Jean Chevronnet 2026-07-14 02:24:13 +00:00
parent a7aacf347e
commit 90824513a7
No known key found for this signature in database
3 changed files with 21 additions and 3 deletions

View file

@ -63,6 +63,7 @@ impl Service for OperServ {
Some(cmd) if cmd.eq_ignore_ascii_case("SQLINE") => xline::SQLINE.handle(me, from, args, ctx, db), Some(cmd) if cmd.eq_ignore_ascii_case("SQLINE") => xline::SQLINE.handle(me, from, args, ctx, db),
Some(cmd) if cmd.eq_ignore_ascii_case("SNLINE") => xline::SNLINE.handle(me, from, args, ctx, db), Some(cmd) if cmd.eq_ignore_ascii_case("SNLINE") => xline::SNLINE.handle(me, from, args, ctx, db),
Some(cmd) if cmd.eq_ignore_ascii_case("SHUN") => xline::SHUN.handle(me, from, args, ctx, db), Some(cmd) if cmd.eq_ignore_ascii_case("SHUN") => xline::SHUN.handle(me, from, args, ctx, db),
Some(cmd) if cmd.eq_ignore_ascii_case("CBAN") => xline::CBAN.handle(me, from, args, ctx, db),
Some(cmd) if cmd.eq_ignore_ascii_case("GLOBAL") => global::handle(me, from, args, ctx), Some(cmd) if cmd.eq_ignore_ascii_case("GLOBAL") => global::handle(me, from, args, ctx),
Some(cmd) if cmd.eq_ignore_ascii_case("KILL") => kill::handle(me, from, args, ctx, net), Some(cmd) if cmd.eq_ignore_ascii_case("KILL") => kill::handle(me, from, args, ctx, net),
Some(cmd) if cmd.eq_ignore_ascii_case("KICK") => kick::handle(me, from, args, ctx, net), Some(cmd) if cmd.eq_ignore_ascii_case("KICK") => kick::handle(me, from, args, ctx, net),
@ -87,5 +88,5 @@ impl Service for OperServ {
} }
fn help(me: &str, from: &Sender, ctx: &mut ServiceCtx) { fn help(me: &str, from: &Sender, ctx: &mut ServiceCtx) {
ctx.notice(me, from.uid, "OperServ holds network operator tools (Priv::Admin): \x02AKILL\x02 ADD|DEL|LIST (user@host bans), \x02SQLINE\x02 ADD|DEL|LIST (nick bans), \x02SNLINE\x02 ADD|DEL|LIST (realname bans), \x02SHUN\x02 ADD|DEL|LIST (silence a host), \x02CHANKILL\x02 <#chan> (clear a channel), \x02GLOBAL\x02 <message> (announce to everyone), \x02KILL\x02 <nick> [reason] (disconnect a user), \x02KICK\x02 <#chan> <nick> [reason], \x02MODE\x02 <#chan> <modes> [params], \x02IGNORE\x02 ADD|DEL|LIST (silence a user from services), \x02STATS\x02 (enforcement overview), \x02SVSNICK\x02 <nick> <newnick>, \x02SVSJOIN\x02 <nick> <#chan> [key], \x02INFO\x02 ADD|DEL <target> (staff notes), \x02NEWS\x02 ADD|DEL|LIST <LOGON|OPER> (announcements), \x02OPER\x02 ADD|DEL|LIST (runtime operators), \x02SESSION\x02 LIST|VIEW + \x02EXCEPTION\x02 ADD|DEL|LIST (per-IP session limits), \x02JUPE\x02 <server> | DEL | LIST, \x02LOGSEARCH\x02 [pattern] (search the action log)."); ctx.notice(me, from.uid, "OperServ holds network operator tools (Priv::Admin): \x02AKILL\x02 ADD|DEL|LIST (user@host bans), \x02SQLINE\x02 ADD|DEL|LIST (nick bans), \x02SNLINE\x02 ADD|DEL|LIST (realname bans), \x02SHUN\x02 ADD|DEL|LIST (silence a host), \x02CBAN\x02 ADD|DEL|LIST (ban a channel name), \x02CHANKILL\x02 <#chan> (clear a channel), \x02GLOBAL\x02 <message> (announce to everyone), \x02KILL\x02 <nick> [reason] (disconnect a user), \x02KICK\x02 <#chan> <nick> [reason], \x02MODE\x02 <#chan> <modes> [params], \x02IGNORE\x02 ADD|DEL|LIST (silence a user from services), \x02STATS\x02 (enforcement overview), \x02SVSNICK\x02 <nick> <newnick>, \x02SVSJOIN\x02 <nick> <#chan> [key], \x02INFO\x02 ADD|DEL <target> (staff notes), \x02NEWS\x02 ADD|DEL|LIST <LOGON|OPER> (announcements), \x02OPER\x02 ADD|DEL|LIST (runtime operators), \x02SESSION\x02 LIST|VIEW + \x02EXCEPTION\x02 ADD|DEL|LIST (per-IP session limits), \x02JUPE\x02 <server> | DEL | LIST, \x02LOGSEARCH\x02 [pattern] (search the action log).");
} }

View file

@ -127,6 +127,10 @@ pub const SNLINE: Xline = Xline { kind: "R", name: "SNLINE", target: "realname-r
// drops their commands — a quieter alternative to an AKILL. // drops their commands — a quieter alternative to an AKILL.
pub const SHUN: Xline = Xline { kind: "SHUN", name: "SHUN", target: "user@host", normalize: norm_userhost }; pub const SHUN: Xline = Xline { kind: "SHUN", name: "SHUN", target: "user@host", normalize: norm_userhost };
// CBAN: a channel-name ban. Users can't join (or create) a channel matching the
// mask, which is a channel glob like `#warez*`.
pub const CBAN: Xline = Xline { kind: "CBAN", name: "CBAN", target: "#channel", normalize: norm_channel };
fn norm_userhost(input: &str) -> Option<String> { fn norm_userhost(input: &str) -> Option<String> {
let body = input.rsplit('!').next().unwrap_or(input); let body = input.rsplit('!').next().unwrap_or(input);
let (user, host) = body.split_once('@')?; let (user, host) = body.split_once('@')?;
@ -151,10 +155,19 @@ fn norm_realname(input: &str) -> Option<String> {
(!input.is_empty()).then(|| input.to_string()) (!input.is_empty()).then(|| input.to_string())
} }
fn norm_channel(input: &str) -> Option<String> {
// A channel-name glob: must carry a channel prefix and a non-empty body.
if (!input.starts_with('#') && !input.starts_with('&')) || input.len() < 2 || input.contains(|c: char| c.is_whitespace() || c == ',') {
return None;
}
Some(input.to_ascii_lowercase())
}
// A mask whose every meaningful character is a wildcard would match nearly all. // A mask whose every meaningful character is a wildcard would match nearly all.
// A leading channel prefix is ignored so `#*` counts as too wide.
fn too_wide(mask: &str) -> bool { fn too_wide(mask: &str) -> bool {
let trivial = |s: &str| s.chars().all(|c| c == '*' || c == '?' || c == '.' || c == '@'); let body = mask.strip_prefix('#').or_else(|| mask.strip_prefix('&')).unwrap_or(mask);
trivial(mask) body.is_empty() || body.chars().all(|c| c == '*' || c == '?' || c == '.' || c == '@')
} }
fn human_secs(secs: u64) -> String { fn human_secs(secs: u64) -> String {

View file

@ -1593,6 +1593,7 @@ fn ban_kind_label(kind: &str) -> &'static str {
"Q" => "nick ban", "Q" => "nick ban",
"R" => "realname ban", "R" => "realname ban",
"SHUN" => "shun", "SHUN" => "shun",
"CBAN" => "channel ban",
_ => "network ban", _ => "network ban",
} }
} }
@ -4053,6 +4054,9 @@ mod tests {
assert!(os(&mut e, "000AAAAAS", "SNLINE ADD .*free.money.* spambot").iter().any(|a| matches!(a, NetAction::AddLine { kind, mask, .. } if kind == "R" && mask == ".*free.money.*")), "R-line added"); assert!(os(&mut e, "000AAAAAS", "SNLINE ADD .*free.money.* spambot").iter().any(|a| matches!(a, NetAction::AddLine { kind, mask, .. } if kind == "R" && mask == ".*free.money.*")), "R-line added");
// SHUN drives a SHUN X-line on a user@host mask. // SHUN drives a SHUN X-line on a user@host mask.
assert!(os(&mut e, "000AAAAAS", "SHUN ADD *@noisy.host quiet down").iter().any(|a| matches!(a, NetAction::AddLine { kind, mask, .. } if kind == "SHUN" && mask == "*@noisy.host")), "shun added"); assert!(os(&mut e, "000AAAAAS", "SHUN ADD *@noisy.host quiet down").iter().any(|a| matches!(a, NetAction::AddLine { kind, mask, .. } if kind == "SHUN" && mask == "*@noisy.host")), "shun added");
// CBAN drives a channel-name ban; an all-wildcard channel mask is refused.
assert!(os(&mut e, "000AAAAAS", "CBAN ADD #warez* piracy").iter().any(|a| matches!(a, NetAction::AddLine { kind, mask, .. } if kind == "CBAN" && mask == "#warez*")), "cban added");
assert!(os(&mut e, "000AAAAAS", "CBAN ADD #* everything").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("too wide"))), "wildcard channel refused");
// GLOBAL fans out to every user via the $* server glob. // GLOBAL fans out to every user via the $* server glob.
let out = os(&mut e, "000AAAAAS", "GLOBAL rebooting in 5"); let out = os(&mut e, "000AAAAAS", "GLOBAL rebooting in 5");