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
All checks were successful
CI / check (push) Successful in 6m21s
This commit is contained in:
parent
ef857f56d4
commit
aabb1432e2
5 changed files with 223 additions and 1 deletions
103
src/config.rs
103
src/config.rs
|
|
@ -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").
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue