Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 45 additions & 27 deletions src/cmds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3466,10 +3466,11 @@ fn deliver_one_recipient(stg: &mut ServerState, id: usize, raw: &str, text: &str
}

if valid_channel(raw) {
deliver_to_channel(stg, id, raw, text, is_priv);
// A channel may hold members on other servers. Relaying after local
// delivery keeps the local view authoritative if the link is down.
relay_to_links(stg, id, raw, text, is_priv);
// A refused send must not cross the link either, or the peer would
// still show a line this server told the sender it rejected.
if deliver_to_channel(stg, id, raw, text, is_priv) {
relay_to_links(stg, id, raw, text, is_priv);
}
return;
}

Expand Down Expand Up @@ -3775,7 +3776,32 @@ fn deliver_to_server_mask(stg: &mut ServerState, id: usize, raw: &str, text: &st
}
}

/// Channel dispatch honoring +i/+n and moderated(+m) gates under locked reply policies.
/// True when this client must not send to the channel.
///
/// A `+b` mask blocks speech, not only joining. Kick removes membership and
/// nothing else, so without this check a banned user keeps talking from
/// outside — the ban is announced, the nick leaves the names list, and the
/// lines still arrive. `+e` overrides the ban. `+n` blocks outsiders only;
/// members still speak. `+m` blocks anyone who is not op or voice. `+i`
/// blocks outsiders.
fn channel_send_denied(stg: &ServerState, id: usize, norm: &str) -> bool {
let banned = match (stg.find_by_id(id), stg.chan(norm)) {
(Some(u), Some(c)) => c.ban_match(u).is_some() && !c.except_match(u),
(None, Some(_)) => true,
_ => return true,
};
match stg.chan(norm) {
Some(c) => {
let member = c.is_member(id);
banned
|| (c.nomsg() && !member)
|| (c.moderated() && !c.is_op(id) && !c.is_voiced(id))
|| (c.invite_only() && !member)
}
None => true,
}
}

/// STATUSMSG target parse: "@#chan" or "+#chan" -> (prefix, channel).
fn split_statusmsg(raw: &str) -> Option<(char, &str)> {
let mut chars = raw.chars();
Expand Down Expand Up @@ -3811,15 +3837,7 @@ fn deliver_to_channel_status(
}
return;
}
let gate_denied = match stg.chan(&norm) {
Some(c) => {
c.nomsg()
|| (c.moderated() && !c.is_op(id) && !c.is_voiced(id))
|| (c.invite_only() && !c.is_member(id))
}
None => true,
};
if gate_denied {
if channel_send_denied(stg, id, &norm) {
if is_priv {
numeric(stg, id, "404", &[full_target, "Cannot send to channel"]);
}
Expand Down Expand Up @@ -3853,35 +3871,34 @@ fn deliver_to_channel_status(
}
}

fn deliver_to_channel(stg: &mut ServerState, id: usize, raw: &str, text: &str, is_priv: bool) {
/// Deliver one channel message. Returns false when the send was refused, so
/// the caller does not relay it to other servers.
fn deliver_to_channel(
stg: &mut ServerState,
id: usize,
raw: &str,
text: &str,
is_priv: bool,
) -> bool {
let norm_key: String = raw.fold_rfc1459();

if stg.chan(&norm_key).is_none() {
if is_priv {
deliver_nosuch_channel(stg, id, raw);
}
return;
return false;
}

let members_now: Vec<usize> = stg
.chan(&norm_key)
.map(|c| c.members.iter().copied().collect::<Vec<usize>>())
.unwrap_or_default();

let gate_denied: bool = match stg.chan(&norm_key) {
Some(c) => {
c.nomsg()
|| (c.moderated() && !c.is_op(id) && !c.is_voiced(id))
|| (c.invite_only() && !c.is_member(id))
}
None => true,
};

if gate_denied {
if channel_send_denied(stg, id, &norm_key) {
if is_priv {
numeric(stg, id, "404", &[raw, "Cannot send to channel"]);
} // recipient token first via the shared chokepoint
return;
return false;
}

let prefix_now: String = stg.find_by_id(id).map(|u| u.prefix()).unwrap_or_default();
Expand All @@ -3901,6 +3918,7 @@ fn deliver_to_channel(stg: &mut ServerState, id: usize, raw: &str, text: &str, i
),
);
}
true
}

/// Split a user@host recipient token into its two lowercased parts.
Expand Down
93 changes: 93 additions & 0 deletions tests/ircv3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -928,3 +928,96 @@ fn bot_and_account_tags_follow_message_tags() {
"WHOIS missing RPL_WHOISBOT: {whois:?}"
);
}

#[test]
fn ban_and_kick_stop_channel_messages() {
// A +b used to affect only JOIN. Kick removed the nick from the names list
// and the ban was announced, but PRIVMSG to the channel kept being delivered.
let addr = start_server();
let mut op = client(&addr);
register(&mut op, "mod");
send(&mut op, "JOIN #lobby");
let _ = drain_until(&mut op, " 366 ", 3);

let mut biff = client(&addr);
register(&mut biff, "biff");
send(&mut biff, "JOIN #lobby");
let _ = drain_until(&mut biff, " 366 ", 3);

let mut hearer = client(&addr);
register(&mut hearer, "hearer");
send(&mut hearer, "JOIN #lobby");
let _ = drain_until(&mut hearer, " 366 ", 3);
let _ = drain_until(&mut op, "hearer", 2);

send(&mut biff, "PRIVMSG #lobby :before");
let before = drain_until(&mut hearer, "before", 3);
assert!(
before.contains("before"),
"a member could not speak before the ban: {before:?}"
);

send(&mut op, "MODE #lobby +b *!*@*");
let _ = drain_until(&mut op, "MODE #lobby +b", 2);

// Still joined, but the ban now blocks both PRIVMSG and NOTICE. NOTICE
// stays silent: no second 404.
send(&mut biff, "PRIVMSG #lobby :banned-still-in");
send(&mut biff, "NOTICE #lobby :banned-notice");
send(&mut op, "KICK #lobby biff :flood");
send(&mut biff, "PRIVMSG #lobby :after-kick");

let from_biff = drain_for(&mut biff, 1);
let kicks = from_biff.matches(" 404 ").count();
assert!(
from_biff.contains("KICK #lobby biff"),
"biff was not kicked: {from_biff:?}"
);
assert_eq!(
kicks, 2,
"banned speech should be two 404s (PRIVMSG in channel, PRIVMSG after kick) and NOTICE must stay silent: {from_biff:?}"
);

let heard = drain_for(&mut hearer, 1);
assert!(
heard.contains("KICK #lobby biff"),
"hearer missed the kick: {heard:?}"
);
for leaked in ["banned-still-in", "banned-notice", "after-kick"] {
assert!(
!heard.contains(leaked),
"channel still received {leaked} after ban/kick: {heard:?}"
);
}

// +e overrides the ban. With no +n, the kicked user can speak again.
send(&mut op, "MODE #lobby +e *!*@*");
let _ = drain_until(&mut op, "MODE #lobby +e", 2);
send(&mut biff, "PRIVMSG #lobby :excepted");
let excepted = drain_until(&mut hearer, "excepted", 3);
assert!(
excepted.contains("excepted"),
"+e did not let the outsider speak: {excepted:?}"
);

// +n then blocks outsiders and still lets members speak.
send(&mut op, "MODE #lobby +n");
let _ = drain_until(&mut op, "MODE #lobby +n", 2);
send(&mut biff, "PRIVMSG #lobby :outsider");
let outsider = drain_until(&mut biff, " 404 ", 3);
assert!(
outsider.contains(" 404 "),
"+n let a non-member speak: {outsider:?}"
);
send(&mut hearer, "PRIVMSG #lobby :member-speaks");
let member = drain_until(&mut op, "member-speaks", 3);
assert!(
member.contains("member-speaks"),
"+n silenced a member: {member:?}"
);
let quiet = drain_for(&mut hearer, 1);
assert!(
!quiet.contains("outsider"),
"+n outsider line was delivered: {quiet:?}"
);
}
Loading