security: behavioural detectors (nick-flood, cycle, join-spam-part, mass-join, quit-flood) on native join/part/quit/nick events
All checks were successful
CI / check (push) Successful in 6m21s

This commit is contained in:
Jean Chevronnet 2026-08-27 00:25:57 +00:00
parent ef857f56d4
commit aabb1432e2
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
5 changed files with 223 additions and 1 deletions

View file

@ -102,6 +102,10 @@ pub struct Security {
// armed, killed + G-lined). The native equivalent of the ozone/Sigyn pattern DB.
#[serde(default)]
pub pattern: Vec<Pattern>,
// Behavioural heuristics on join/part/quit/nick events (cycle, join-spam-part,
// broken-client quit flood, mass-join, nick-change flood).
#[serde(default)]
pub behavior: BehaviorRules,
}
#[derive(Debug, Deserialize, Clone)]
@ -187,6 +191,105 @@ fn pat_reason() -> String {
"matched a security pattern".to_string()
}
#[derive(Debug, Deserialize, Clone)]
pub struct BehaviorRules {
#[serde(default = "sec_true")]
pub enabled: bool,
// Nick-change flood: > nick_permit changes from one user within nick_life s.
#[serde(default = "sec_nick_permit")]
pub nick_permit: u32,
#[serde(default = "sec_nick_life")]
pub nick_life: u64,
// Join/part cycling: > cycle_permit parts from one user within cycle_life s.
#[serde(default = "sec_cycle_permit")]
pub cycle_permit: u32,
#[serde(default = "sec_cycle_life")]
pub cycle_life: u64,
// Join-spam-part: > joinpart_permit parts each within joinpart_grace s of the
// join, counted over joinpart_life s.
#[serde(default = "sec_joinpart_permit")]
pub joinpart_permit: u32,
#[serde(default = "sec_joinpart_life")]
pub joinpart_life: u64,
#[serde(default = "sec_joinpart_grace")]
pub joinpart_grace: u64,
// Mass-join: > massjoin_permit joins to one channel from a single /24 (v4) /
// /64 (v6) within massjoin_life s — the clone-raid signal.
#[serde(default = "sec_massjoin_permit")]
pub massjoin_permit: u32,
#[serde(default = "sec_massjoin_life")]
pub massjoin_life: u64,
// Broken-client quit flood: > quit_permit quits whose reason contains one of
// `quit_reasons` from one IP within quit_life s.
#[serde(default = "sec_quit_permit")]
pub quit_permit: u32,
#[serde(default = "sec_quit_life")]
pub quit_life: u64,
#[serde(default = "sec_quit_reasons")]
pub quit_reasons: Vec<String>,
// Seconds the auto G-line lasts when armed (0 = kill the connection only).
#[serde(default = "sec_conn_ban")]
pub ban_duration: u64,
}
impl Default for BehaviorRules {
fn default() -> Self {
Self {
enabled: sec_true(),
nick_permit: sec_nick_permit(),
nick_life: sec_nick_life(),
cycle_permit: sec_cycle_permit(),
cycle_life: sec_cycle_life(),
joinpart_permit: sec_joinpart_permit(),
joinpart_life: sec_joinpart_life(),
joinpart_grace: sec_joinpart_grace(),
massjoin_permit: sec_massjoin_permit(),
massjoin_life: sec_massjoin_life(),
quit_permit: sec_quit_permit(),
quit_life: sec_quit_life(),
quit_reasons: sec_quit_reasons(),
ban_duration: sec_conn_ban(),
}
}
}
fn sec_nick_permit() -> u32 {
5
}
fn sec_nick_life() -> u64 {
30
}
fn sec_cycle_permit() -> u32 {
6
}
fn sec_cycle_life() -> u64 {
20
}
fn sec_joinpart_permit() -> u32 {
4
}
fn sec_joinpart_life() -> u64 {
30
}
fn sec_joinpart_grace() -> u64 {
10
}
fn sec_massjoin_permit() -> u32 {
8
}
fn sec_massjoin_life() -> u64 {
8
}
fn sec_quit_permit() -> u32 {
4
}
fn sec_quit_life() -> u64 {
30
}
fn sec_quit_reasons() -> Vec<String> {
vec!["Excess Flood".to_string(), "Max SendQ exceeded".to_string()]
}
#[derive(Debug, Deserialize, Clone)]
pub struct Language {
// Reply language for users who haven't picked one (a code like "en" or "fr").

View file

@ -1574,6 +1574,7 @@ impl Engine {
if let Some(line) = self.notify_line('n', &uid, None, &format!("changed nick (was {old_nick})")) {
out.push(line);
}
out.extend(self.security_screen_nick(&uid));
out
}
// The uplink finished its initial burst: from here on connections are
@ -1637,6 +1638,7 @@ impl Engine {
// Computed before the match below moves `channel` into its actions.
let greet = self.greet_on_join(&channel, account.as_deref());
let mut acts = std::mem::take(&mut watch);
acts.extend(self.security_screen_join(&uid, &channel));
// A services bot assigned here is opped on join and is never subject
// to secureops/restricted — it's staff, not a member.
let is_bot = self.bot_uids.values().any(|b| b == &uid);
@ -1692,6 +1694,7 @@ impl Engine {
let what = if reason.is_empty() { format!("left {channel}") } else { format!("left {channel} ({reason})") };
let mut out: Vec<NetAction> = self.notify_line('p', &uid, Some(&channel), &what).into_iter().collect();
out.extend(self.member_left(&channel, &uid));
out.extend(self.security_screen_part(&uid, &channel));
out
}
NetEvent::Kicked { channel, uid, by, reason } => {
@ -1806,6 +1809,7 @@ impl Engine {
// Match before we forget them, so their identity is still resolvable.
let what = if reason.is_empty() { "disconnected".to_string() } else { format!("disconnected ({reason})") };
let mut out: Vec<NetAction> = self.notify_line('d', &uid, None, &what).into_iter().collect();
out.extend(self.security_screen_quit(&uid, &reason));
out.extend(self.forget_user(&uid));
out
}

View file

@ -37,7 +37,6 @@ impl Counters {
}
// Peek a key's current count without recording an event.
#[allow(dead_code)]
pub fn count(&mut self, key: &str, now: u64, life: u64) -> u32 {
let cutoff = now.saturating_sub(life);
match self.windows.get_mut(key) {

View file

@ -169,6 +169,13 @@ fn ip_in_cidr(ip: &str, cidr: &str) -> bool {
}
}
// A quit reason is a "broken client" signal when it contains any configured marker
// (case-insensitive substring), e.g. "Excess Flood" / "Max SendQ exceeded".
fn quit_matches(reason: &str, markers: &[String]) -> bool {
let r = reason.to_ascii_lowercase();
markers.iter().any(|m| !m.is_empty() && r.contains(&m.to_ascii_lowercase()))
}
// The coarse aggregation key for an address — the /24 for IPv4, the /64 for IPv6
// — used to catch clone floods spread across a subnet.
fn cidr_of(ip: &str) -> String {
@ -272,6 +279,97 @@ impl super::Engine {
}
out
}
// The behavioural rules, if the subsystem and the behaviour detectors are on.
fn behavior_rules(&self) -> Option<config::BehaviorRules> {
let c = self.security.cfg.as_ref()?;
(c.enabled && c.behavior.enabled).then(|| c.behavior.clone())
}
// (ip, nick!ident@host) for a uid, or None when it can't be resolved or its IP is
// exempt — the shared gate for every behavioural detector.
fn security_identity(&self, uid: &str) -> Option<(String, String)> {
let (ip, who) = self.network.abuse_ident(uid)?;
if ip.is_empty() || self.security.is_exempt(&ip) {
return None;
}
Some((ip, who))
}
// Nick-change flood.
pub(crate) fn security_screen_nick(&mut self, uid: &str) -> Vec<NetAction> {
let Some(rules) = self.behavior_rules() else {
return Vec::new();
};
let Some((ip, who)) = self.security_identity(uid) else {
return Vec::new();
};
let now = self.now_secs();
if self.security.counters.hit(&format!("nf|{uid}"), now, rules.nick_life) > rules.nick_permit {
self.bump("security.nick.trips");
return self.security_act(&who, uid, &format!("*@{ip}"), "nick-change flood", &format!("more than {} nick changes in {}s", rules.nick_permit, rules.nick_life), rules.ban_duration);
}
Vec::new()
}
// On join: note it briefly (for join-spam-part) and check mass-join per /24 or /64.
pub(crate) fn security_screen_join(&mut self, uid: &str, channel: &str) -> Vec<NetAction> {
let Some(rules) = self.behavior_rules() else {
return Vec::new();
};
let Some((ip, who)) = self.security_identity(uid) else {
return Vec::new();
};
let now = self.now_secs();
self.security.counters.hit(&format!("jj|{uid}|{channel}"), now, rules.joinpart_grace);
let range = cidr_of(&ip);
let n = self.security.counters.hit(&format!("mj|{channel}|{range}"), now, rules.massjoin_life);
if n > rules.massjoin_permit {
self.bump("security.massjoin.trips");
return self.security_act(&who, uid, &format!("*@{range}"), "mass-join flood", &format!("{n} joins to {channel} from range {range} in {}s", rules.massjoin_life), rules.ban_duration);
}
Vec::new()
}
// On part: raw cycle rate, then quick join-then-part (join-spam-part).
pub(crate) fn security_screen_part(&mut self, uid: &str, channel: &str) -> Vec<NetAction> {
let Some(rules) = self.behavior_rules() else {
return Vec::new();
};
let Some((ip, who)) = self.security_identity(uid) else {
return Vec::new();
};
let now = self.now_secs();
if self.security.counters.hit(&format!("cy|{uid}"), now, rules.cycle_life) > rules.cycle_permit {
self.bump("security.cycle.trips");
return self.security_act(&who, uid, &format!("*@{ip}"), "join/part cycle", &format!("more than {} parts in {}s", rules.cycle_permit, rules.cycle_life), rules.ban_duration);
}
let quick = self.security.counters.count(&format!("jj|{uid}|{channel}"), now, rules.joinpart_grace) > 0;
if quick && self.security.counters.hit(&format!("jsp|{uid}"), now, rules.joinpart_life) > rules.joinpart_permit {
self.bump("security.joinspampart.trips");
return self.security_act(&who, uid, &format!("*@{ip}"), "join-spam-part", &format!("repeated quick join/part (last: {channel})"), rules.ban_duration);
}
Vec::new()
}
// On quit: broken-client quit flood — repeated flood/sendq kills from one IP.
pub(crate) fn security_screen_quit(&mut self, uid: &str, reason: &str) -> Vec<NetAction> {
let Some(rules) = self.behavior_rules() else {
return Vec::new();
};
if !quit_matches(reason, &rules.quit_reasons) {
return Vec::new();
}
let Some((ip, who)) = self.security_identity(uid) else {
return Vec::new();
};
let now = self.now_secs();
if self.security.counters.hit(&format!("qf|{ip}"), now, rules.quit_life) > rules.quit_permit {
self.bump("security.quitflood.trips");
return self.security_act(&who, uid, &format!("*@{ip}"), "broken-client quit flood", &format!("repeated \"{reason}\" from {ip}"), rules.ban_duration);
}
Vec::new()
}
}
#[cfg(test)]
@ -316,4 +414,14 @@ mod tests {
assert!(sec.match_pattern("BOT42", "x", "clean.host", "g").is_some()); // case-insensitive regex on nick
assert!(sec.match_pattern("alice", "x", "clean.host", "hello").is_none());
}
#[test]
fn quit_reason_matching() {
let m = vec!["Excess Flood".to_string(), "Max SendQ exceeded".to_string()];
assert!(quit_matches("Excess Flood", &m));
assert!(quit_matches("Closing Link: nick[1.2.3.4] (Excess Flood)", &m)); // case-insensitive substring
assert!(!quit_matches("Ping timeout: 240 seconds", &m));
assert!(!quit_matches("Quit: brb", &m));
assert!(!quit_matches("Excess Flood", &[])); // no markers => never
}
}

View file

@ -388,6 +388,14 @@ impl Network {
})
}
// A minimal identity snapshot for the anti-abuse detectors — (ip, nick!ident@host)
// in a single lookup, skipping ban_target's channel-list allocation (these run on
// every join/part/quit/nick, not just on connect).
pub fn abuse_ident(&self, uid: &str) -> Option<(String, String)> {
let u = self.users.get(uid)?;
Some((u.ip.clone(), format!("{}!{}@{}", u.nick, u.ident, u.host)))
}
/// The channels `uid` is currently in (for the `channel` extban).
pub fn channels_of(&self, uid: &str) -> Vec<String> {
self.channels.iter().filter(|(_, c)| c.members.contains(uid)).map(|(k, _)| k.clone()).collect()