diff --git a/src/engine/dispatch.rs b/src/engine/dispatch.rs index 4bdb107..064540d 100644 --- a/src/engine/dispatch.rs +++ b/src/engine/dispatch.rs @@ -34,13 +34,39 @@ impl Engine { self.bump("botserv.messages"); } } else { - // A services ignore silences a user's commands entirely — but an oper - // can never be ignored (else they could lock themselves out). + // A services ignore silences a user's commands entirely, and flood + // control clamps a bot spamming a service in a loop — but an oper is + // exempt from both (else they could lock themselves out). if !privs.any() { let host = self.network.host_of(from).unwrap_or("").to_string(); if self.db.is_ignored(&nick, &host) { return Vec::new(); } + // Flood control targets the stated vector: a bot that connects and + // spams a service in a loop without ever logging in. Identified users + // are accountable and legitimately burst (channel setup), and the + // ircd's own fakelag still governs everyone — so only anonymous + // senders are clamped, and only on lines aimed at one of our services. + if account.is_none() { + let target = self.services.iter() + .find(|s| to.eq_ignore_ascii_case(s.uid()) || to.eq_ignore_ascii_case(s.nick())) + .map(|s| s.uid().to_string()); + if let Some(svc_uid) = target { + match self.cmd_limiter.check(&host) { + CmdVerdict::Allow => {} + CmdVerdict::Warn => { + let mut out = vec![NetAction::Notice { + from: svc_uid, + to: from.to_string(), + text: "You're sending commands too fast. Slow down and try again in a moment.".to_string(), + }]; + self.apply_msg_style(&mut out); + return out; + } + CmdVerdict::Drop => return Vec::new(), + } + } + } } let mut matched: Option = None; { diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 6f3bda8..9baad36 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -94,6 +94,7 @@ pub struct Engine { sasl_sessions: HashMap, // client uid -> in-progress exchange sasl_source: HashMap, // client uid -> real host/IP from the SASL H message reg_limiter: RegLimiter, + cmd_limiter: CmdLimiter, // per-host flood control for service commands chan_service: Option, // uid to source channel modes from (ChanServ) nick_service: Option, // uid of the account service (NickServ), for its notices irc_out: Option>, // services-initiated actions -> the uplink @@ -234,6 +235,7 @@ impl Engine { sasl_sessions: HashMap::new(), sasl_source: HashMap::new(), reg_limiter: RegLimiter::new(), + cmd_limiter: CmdLimiter::default(), chan_service, nick_service, irc_out: None, @@ -1956,5 +1958,73 @@ impl RegLimiter { } } +// Per-host flood control for anonymous service commands. A bot that connects and +// spams `/msg NickServ HELP` in a loop is warned, then silently dropped until it +// slows down — never answered per line, which would just double the flood on +// the wire. A sender may burst up to CMD_BURST commands, then is held to +// CMD_REFILL_PER_SEC sustained (one every two seconds). Keyed by host so a nick +// change or reconnect does not reset the count. Ephemeral; never event-logged. +#[derive(Default)] +struct CmdLimiter { + buckets: HashMap, + last_sweep: Option, +} + +struct CmdBucket { + tokens: f64, + last: Instant, + last_warn: Option, // when we last told this host to slow down +} + +const CMD_BURST: f64 = 15.0; // commands allowed back-to-back from a full bucket +const CMD_REFILL_PER_SEC: f64 = 0.5; // steady-state: one every two seconds +const CMD_WARN_EVERY: Duration = Duration::from_secs(30); // at most one "slow down" per host per window +const CMD_SWEEP_EVERY: Duration = Duration::from_secs(60); + +enum CmdVerdict { + Allow, // within budget: run the command + Warn, // just went over: send one "slow down" notice, drop the command + Drop, // still over and already warned: drop silently +} + +impl CmdLimiter { + fn check(&mut self, key: &str) -> CmdVerdict { + self.check_at(key, Instant::now()) + } + + fn check_at(&mut self, key: &str, now: Instant) -> CmdVerdict { + self.sweep(now); + let b = self.buckets.entry(key.to_string()).or_insert(CmdBucket { tokens: CMD_BURST, last: now, last_warn: None }); + b.tokens = (b.tokens + now.duration_since(b.last).as_secs_f64() * CMD_REFILL_PER_SEC).min(CMD_BURST); + b.last = now; + if b.tokens >= 1.0 { + b.tokens -= 1.0; + return CmdVerdict::Allow; + } + // Over budget. Warn at most once per window, then drop silently — a sustained + // flood that the ircd's fakelag paces to a trickle must not draw a fresh + // "slow down" on every other line, which would just backscatter the flood. + if b.last_warn.is_none_or(|w| now.duration_since(w) >= CMD_WARN_EVERY) { + b.last_warn = Some(now); + CmdVerdict::Warn + } else { + CmdVerdict::Drop + } + } + + // Drop buckets that have refilled to full since we last touched them: a full + // bucket is indistinguishable from a fresh one, so this is lossless and keeps + // the map bounded to recently-active senders. Runs at most once a minute. + fn sweep(&mut self, now: Instant) { + if let Some(last) = self.last_sweep { + if now.duration_since(last) < CMD_SWEEP_EVERY { + return; + } + } + self.last_sweep = Some(now); + self.buckets.retain(|_, b| (b.tokens + now.duration_since(b.last).as_secs_f64() * CMD_REFILL_PER_SEC) < CMD_BURST); + } +} + #[cfg(test)] mod tests; diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 18c2b0c..0ac47f6 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -5355,3 +5355,32 @@ fn chanfix_and_diceserv_basic_paths() { // DiceServ evaluates an expression. assert!(svc_ask(&mut e, "42SAAAAAI", "ROLL 2d6").iter().any(|l| l.contains('=')), "dice result"); } + +#[test] +fn cmd_limiter_bursts_then_throttles_per_host() { + let mut lim = CmdLimiter::default(); + let t0 = Instant::now(); + // A full bucket lets a whole burst through back-to-back. + for _ in 0..CMD_BURST as usize { + assert!(matches!(lim.check_at("1.2.3.4", t0), CmdVerdict::Allow), "burst within budget"); + } + // Going over budget warns once, then drops silently — never a fresh warning per + // line, which would just backscatter the flood. + assert!(matches!(lim.check_at("1.2.3.4", t0), CmdVerdict::Warn), "first over-budget warns"); + assert!(matches!(lim.check_at("1.2.3.4", t0), CmdVerdict::Drop), "no repeat warning"); + assert!(matches!(lim.check_at("1.2.3.4", t0), CmdVerdict::Drop), "stays dropped"); + // A different host keeps its own independent budget. + assert!(matches!(lim.check_at("9.9.9.9", t0), CmdVerdict::Allow), "other host independent"); + // A trickle refill lets a couple through, but an abuser the ircd is already + // pacing must not be re-warned each time the bucket briefly recovers. + let paced = t0 + Duration::from_secs(4); // +2 tokens + assert!(matches!(lim.check_at("1.2.3.4", paced), CmdVerdict::Allow), "refilled token 1"); + assert!(matches!(lim.check_at("1.2.3.4", paced), CmdVerdict::Allow), "refilled token 2"); + assert!(matches!(lim.check_at("1.2.3.4", paced), CmdVerdict::Drop), "dropped, not re-warned mid-window"); + // Only once the warn window has elapsed does one fresh warning surface again. + let next_window = t0 + Duration::from_secs(CMD_WARN_EVERY.as_secs() + 5); + for _ in 0..CMD_BURST as usize { + assert!(matches!(lim.check_at("1.2.3.4", next_window), CmdVerdict::Allow), "bucket refilled after idle"); + } + assert!(matches!(lim.check_at("1.2.3.4", next_window), CmdVerdict::Warn), "one fresh warning in a new window"); +}