engine: rate-limit anonymous service commands so a bot cannot spam a service in a loop
This commit is contained in:
parent
321e03ac48
commit
86b1aae686
3 changed files with 127 additions and 2 deletions
|
|
@ -34,13 +34,39 @@ impl Engine {
|
||||||
self.bump("botserv.messages");
|
self.bump("botserv.messages");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// A services ignore silences a user's commands entirely — but an oper
|
// A services ignore silences a user's commands entirely, and flood
|
||||||
// can never be ignored (else they could lock themselves out).
|
// 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() {
|
if !privs.any() {
|
||||||
let host = self.network.host_of(from).unwrap_or("").to_string();
|
let host = self.network.host_of(from).unwrap_or("").to_string();
|
||||||
if self.db.is_ignored(&nick, &host) {
|
if self.db.is_ignored(&nick, &host) {
|
||||||
return Vec::new();
|
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<String> = None;
|
let mut matched: Option<String> = None;
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ pub struct Engine {
|
||||||
sasl_sessions: HashMap<String, TimedSession>, // client uid -> in-progress exchange
|
sasl_sessions: HashMap<String, TimedSession>, // client uid -> in-progress exchange
|
||||||
sasl_source: HashMap<String, (Instant, String)>, // client uid -> real host/IP from the SASL H message
|
sasl_source: HashMap<String, (Instant, String)>, // client uid -> real host/IP from the SASL H message
|
||||||
reg_limiter: RegLimiter,
|
reg_limiter: RegLimiter,
|
||||||
|
cmd_limiter: CmdLimiter, // per-host flood control for service commands
|
||||||
chan_service: Option<String>, // uid to source channel modes from (ChanServ)
|
chan_service: Option<String>, // uid to source channel modes from (ChanServ)
|
||||||
nick_service: Option<String>, // uid of the account service (NickServ), for its notices
|
nick_service: Option<String>, // uid of the account service (NickServ), for its notices
|
||||||
irc_out: Option<mpsc::UnboundedSender<NetAction>>, // services-initiated actions -> the uplink
|
irc_out: Option<mpsc::UnboundedSender<NetAction>>, // services-initiated actions -> the uplink
|
||||||
|
|
@ -234,6 +235,7 @@ impl Engine {
|
||||||
sasl_sessions: HashMap::new(),
|
sasl_sessions: HashMap::new(),
|
||||||
sasl_source: HashMap::new(),
|
sasl_source: HashMap::new(),
|
||||||
reg_limiter: RegLimiter::new(),
|
reg_limiter: RegLimiter::new(),
|
||||||
|
cmd_limiter: CmdLimiter::default(),
|
||||||
chan_service,
|
chan_service,
|
||||||
nick_service,
|
nick_service,
|
||||||
irc_out: None,
|
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<String, CmdBucket>,
|
||||||
|
last_sweep: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CmdBucket {
|
||||||
|
tokens: f64,
|
||||||
|
last: Instant,
|
||||||
|
last_warn: Option<Instant>, // 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)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|
|
||||||
|
|
@ -5355,3 +5355,32 @@ fn chanfix_and_diceserv_basic_paths() {
|
||||||
// DiceServ evaluates an expression.
|
// DiceServ evaluates an expression.
|
||||||
assert!(svc_ask(&mut e, "42SAAAAAI", "ROLL 2d6").iter().any(|l| l.contains('=')), "dice result");
|
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");
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue