From b2ffc01e529149b53c7e4c118a3a7286f02dbae2 Mon Sep 17 00:00:00 2001 From: Jean Date: Sun, 19 Jul 2026 13:34:48 +0000 Subject: [PATCH] Bound dice batches, game count, and report/help queues, and harden community votekick --- modules/diceserv/src/expr.rs | 5 +++-- modules/diceserv/src/roll.rs | 10 ++++++++++ modules/gameserv/src/lib.rs | 8 ++++++++ modules/helpserv/src/request.rs | 7 ++++++- modules/reportserv/src/report.rs | 7 ++++++- src/engine/db/network.rs | 14 ++++++++++++++ src/engine/kicker.rs | 15 ++++++++++++--- 7 files changed, 59 insertions(+), 7 deletions(-) diff --git a/modules/diceserv/src/expr.rs b/modules/diceserv/src/expr.rs index 52b2444..08e553c 100644 --- a/modules/diceserv/src/expr.rs +++ b/modules/diceserv/src/expr.rs @@ -9,7 +9,7 @@ use rand::Rng; // Guardrails so a single expression can't ask us to roll forever. const MAX_DICE: u64 = 99_999; // dice in one NdM roll const MAX_SIDES: u64 = 99_999; // sides on a die -const MAX_TOTAL: u64 = 1_000_000; // dice rolled across the whole expression +pub const MAX_TOTAL: u64 = 1_000_000; // dice rolled across the whole expression // One resolved dice roll, kept for the extended (EX) output. pub struct Roll { @@ -21,6 +21,7 @@ pub struct Roll { pub struct Evaluated { pub value: f64, pub rolls: Vec, + pub total: u64, // dice rolled in this evaluation, for cross-repeat budgeting } // ---- tokens ---------------------------------------------------------------- @@ -230,7 +231,7 @@ pub fn evaluate(input: &str, rng: &mut impl Rng) -> Result { if !value.is_finite() { return Err("that doesn't come out to a real number".to_string()); } - Ok(Evaluated { value, rolls }) + Ok(Evaluated { value, rolls, total: total_dice }) } fn eval(ast: &Ast, rng: &mut impl Rng, rolls: &mut Vec, total: &mut u64) -> Result { diff --git a/modules/diceserv/src/roll.rs b/modules/diceserv/src/roll.rs index 85d4797..eb0929e 100644 --- a/modules/diceserv/src/roll.rs +++ b/modules/diceserv/src/roll.rs @@ -27,9 +27,11 @@ pub fn handle(me: &str, from: &Sender, rest: &[&str], ctx: &mut ServiceCtx, exte }; let mut rng = rand::thread_rng(); + let mut spent = 0u64; // dice rolled across the whole repeat batch for i in 0..times { match expr::evaluate(body, &mut rng) { Ok(ev) => { + spent += ev.total; let result = format_value(ev.value, round_result); let prefix = if times > 1 { format!("[{}/{}] ", i + 1, times) } else { String::new() }; let detail = if extended && !ev.rolls.is_empty() { @@ -38,6 +40,14 @@ pub fn handle(me: &str, from: &Sender, rest: &[&str], ctx: &mut ServiceCtx, exte String::new() }; ctx.notice(me, from.uid, format!("{prefix}\x02{body}\x02 = \x02{result}\x02{detail}")); + // Bound total work across the whole `N~` batch, not per-evaluate, so + // `25~99999d1+…` can't multiply the per-roll cap into tens of millions. + if spent >= expr::MAX_TOTAL { + if i + 1 < times { + ctx.notice(me, from.uid, "That's a lot of dice — stopping the repeats here."); + } + break; + } } Err(why) => { ctx.notice(me, from.uid, format!("I couldn't roll \x02{body}\x02: {why}.")); diff --git a/modules/gameserv/src/lib.rs b/modules/gameserv/src/lib.rs index 640cdc6..479e576 100644 --- a/modules/gameserv/src/lib.rs +++ b/modules/gameserv/src/lib.rs @@ -141,6 +141,14 @@ impl GameServ { ctx.notice(me, from.uid, "You cannot challenge yourself."); return; } + // Global cap: the per-user cap keys on a churnable identity (a guest's nick), + // so a single connection could otherwise cycle nicks to grow the games map + // without bound. Bounding the total makes that impossible regardless. + const MAX_GAMES_TOTAL: usize = 500; + if self.games.len() >= MAX_GAMES_TOTAL { + ctx.notice(me, from.uid, "The game server is at capacity right now. Please try again shortly."); + return; + } let meid = Self::ident(from).to_string(); let mine = self.games.values().filter(|g| g.acc_a == meid || g.acc_b == meid).count(); if mine >= MAX_GAMES_PER_USER { diff --git a/modules/helpserv/src/request.rs b/modules/helpserv/src/request.rs index 11fa346..27cb46e 100644 --- a/modules/helpserv/src/request.rs +++ b/modules/helpserv/src/request.rs @@ -7,7 +7,12 @@ pub fn handle(me: &str, from: &Sender, rest: &[&str], ctx: &mut ServiceCtx, db: ctx.notice(me, from.uid, "Tell us what you need help with: REQUEST "); return; } - let requester = from.account.unwrap_or(from.nick); + // Require identification, so the cooldown keys on a stable account rather than a + // spoofable nick a flooder can cycle to reset it. + let Some(requester) = from.account else { + ctx.notice(me, from.uid, "Please identify to NickServ before opening a ticket."); + return; + }; match db.help_request(requester, &message) { Some(id) => ctx.notice(me, from.uid, format!("Thanks — your request (\x02#{id}\x02) is in the queue. A staff member will be with you.")), None => ctx.notice(me, from.uid, "You just opened a ticket — please wait a moment before opening another."), diff --git a/modules/reportserv/src/report.rs b/modules/reportserv/src/report.rs index 0ae80b5..e87d8f2 100644 --- a/modules/reportserv/src/report.rs +++ b/modules/reportserv/src/report.rs @@ -11,7 +11,12 @@ pub fn handle(me: &str, from: &Sender, rest: &[&str], ctx: &mut ServiceCtx, db: return; } let reason = reason_words.join(" "); - let reporter = from.account.unwrap_or(from.nick); + // Require identification, so the cooldown keys on a stable account rather than a + // spoofable nick a flooder can cycle to reset it. + let Some(reporter) = from.account else { + ctx.notice(me, from.uid, "Please identify to NickServ before filing a report."); + return; + }; match db.report_file(reporter, target, &reason) { Some(id) => ctx.notice(me, from.uid, format!("Thanks — your report (\x02#{id}\x02) about \x02{target}\x02 has been sent to the staff.")), None => ctx.notice(me, from.uid, "You just filed a report — please wait a moment before filing another."), diff --git a/src/engine/db/network.rs b/src/engine/db/network.rs index b8d2ef4..41a4398 100644 --- a/src/engine/db/network.rs +++ b/src/engine/db/network.rs @@ -1,5 +1,17 @@ use super::*; +// Bound a moderation queue (reports / help tickets): when it's at the cap, drop the +// oldest resolved entry — or the oldest entry if all are still open — so a flood +// can't grow it without limit. Called before each push. +fn prune_queue(q: &mut Vec, is_open: impl Fn(&T) -> bool) { + const MAX_QUEUE: usize = 2000; + if q.len() < MAX_QUEUE { + return; + } + let pos = q.iter().position(|e| !is_open(e)).unwrap_or(0); + q.remove(pos); +} + impl Db { /// Add (or refresh) a `kind` network ban. Returns whether it was newly added. pub fn akill_add(&mut self, kind: XlineKind, mask: &str, setter: &str, reason: &str, expires: Option) -> Result { @@ -456,6 +468,7 @@ impl Db { return None; } self.report_times.insert(key, now); + prune_queue(&mut self.net.reports, |r| r.open); let id = self.net.report_seq; let _ = self.log.append(Event::ReportFiled { id, reporter: reporter.to_string(), target: target.to_string(), reason: reason.to_string(), ts: now }); self.net.report_seq = id + 1; @@ -511,6 +524,7 @@ impl Db { return None; } self.report_times.insert(tkey, now); + prune_queue(&mut self.net.help, |h| h.open); let id = self.net.help_seq; let _ = self.log.append(Event::HelpRequested { id, requester: requester.to_string(), message: message.to_string(), ts: now }); self.net.help_seq = id + 1; diff --git a/src/engine/kicker.rs b/src/engine/kicker.rs index 2ff09d7..4e09f2e 100644 --- a/src/engine/kicker.rs +++ b/src/engine/kicker.rs @@ -213,16 +213,25 @@ impl Engine { }; let target_nick = target_raw.trim(); if target_nick.is_empty() || target_nick.contains(' ') { - return Some(Vec::new()); + return None; // not a real vote — let the line reach the flood/badword kicker } let threshold = self.db.channel(channel).map(|c| c.kickers.votekick).unwrap_or(0); let botnick = self.db.channel(channel).and_then(|c| c.assigned_bot.clone()); - let (Some(botnick), true) = (botnick, threshold > 0) else { return Some(Vec::new()) }; - let Some(botuid) = self.network.uid_by_nick(&botnick).map(str::to_string) else { return Some(Vec::new()) }; + let (Some(botnick), true) = (botnick, threshold > 0) else { return None }; + let Some(botuid) = self.network.uid_by_nick(&botnick).map(str::to_string) else { return None }; let Some(target_uid) = self.network.uid_by_nick(target_nick).map(str::to_string) else { return Some(vec![NetAction::Privmsg { from: botuid, to: channel.to_string(), text: format!("There's no \x02{target_nick}\x02 here to vote on.") }]); }; let target_display = self.network.nick_of(&target_uid).unwrap_or(target_nick).to_string(); + // Never let the community vote out a service bot, an oper, or a user with + // channel op-access — moderation authority isn't a valid vote target. + let target_account = self.network.account_of(&target_uid).map(str::to_string); + let is_bot = self.bot_uids.values().any(|b| b == &target_uid); + let is_oper = target_account.as_deref().is_some_and(|a| self.oper_privs(a).any()); + let is_chanop = target_account.as_deref().is_some_and(|a| self.db.channel(channel).is_some_and(|c| c.is_op(a))); + if is_bot || is_oper || is_chanop { + return Some(vec![NetAction::Privmsg { from: botuid.clone(), to: channel.to_string(), text: format!("\x02{target_display}\x02 can't be voted out.") }]); + } let now = self.now_secs(); let key = (channel.to_ascii_lowercase(), target_display.to_ascii_lowercase());