diff --git a/api/src/lib.rs b/api/src/lib.rs index b075c67..d4b312b 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -1858,6 +1858,7 @@ pub enum CertError { Invalid, // not a plausible fingerprint InUse, // already registered (to any account) NoAccount, // target account does not exist + Full, // the account's fingerprint list is at its cap Internal, // persistence failed } @@ -1866,6 +1867,7 @@ pub enum ChanError { Exists, // channel already registered NoChannel, // channel is not registered InvalidPattern, // a badword regex didn't compile + Full, // the list (access / akick) is at its cap Internal, // persistence failed } diff --git a/modules/nickserv/src/cert.rs b/modules/nickserv/src/cert.rs index 8aeaf21..e224ee7 100644 --- a/modules/nickserv/src/cert.rs +++ b/modules/nickserv/src/cert.rs @@ -33,6 +33,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: Ok(()) => ctx.notice(me, from.uid, format!("Added fingerprint \x02{fp}\x02. You can now log in with SASL EXTERNAL.")), Err(CertError::InUse) => ctx.notice(me, from.uid, "That fingerprint is already registered."), Err(CertError::Invalid) => ctx.notice(me, from.uid, "That does not look like a certificate fingerprint."), + Err(CertError::Full) => ctx.notice(me, from.uid, "You have too many fingerprints registered. Remove one first."), Err(CertError::NoAccount | CertError::Internal) => ctx.notice(me, from.uid, "Could not add the fingerprint, please try again later."), } } diff --git a/src/engine/db/account.rs b/src/engine/db/account.rs index 7f61bf9..f5060dc 100644 --- a/src/engine/db/account.rs +++ b/src/engine/db/account.rs @@ -632,8 +632,14 @@ impl Db { return Err(CertError::InUse); } let k = key(account); - if !self.accounts.contains_key(&k) { + let Some(acct) = self.accounts.get(&k) else { return Err(CertError::NoAccount); + }; + // Cap the list so an account can't grow certfps (and the replicated log) + // without bound by looping CERT ADD with fabricated fingerprints. + const MAX_CERTFP: usize = 25; + if acct.certfps.len() >= MAX_CERTFP { + return Err(CertError::Full); } self.log.append(Event::CertAdded { account: account.to_string(), fp: fp.clone() }).map_err(|_| CertError::Internal)?; self.accounts.get_mut(&k).unwrap().certfps.push(fp); diff --git a/src/engine/db/channel.rs b/src/engine/db/channel.rs index 04ce736..011f270 100644 --- a/src/engine/db/channel.rs +++ b/src/engine/db/channel.rs @@ -1,5 +1,9 @@ use super::*; +// Per-channel cap on the access and akick lists, bounding the event-log growth an +// op can drive (an event-sourced log is never reclaimed, even by later DEL). +const MAX_CHAN_LIST: usize = 256; + impl Db { /// Register `name` to `founder` (an account name). pub fn register_channel(&mut self, name: &str, founder: &str) -> Result<(), ChanError> { @@ -68,8 +72,12 @@ impl Db { /// Grant `account` a level ("op"/"voice") on `channel`. pub fn access_add(&mut self, channel: &str, account: &str, level: &str) -> Result<(), ChanError> { let k = key(channel); - if !self.channels.contains_key(&k) { + let Some(c0) = self.channels.get(&k) else { return Err(ChanError::NoChannel); + }; + // Same unbounded-growth cap as akick; updating an existing entry is exempt. + if c0.access.len() >= MAX_CHAN_LIST && !c0.access.iter().any(|a| a.account.eq_ignore_ascii_case(account)) { + return Err(ChanError::Full); } self.log .append(Event::ChannelAccessAdd { channel: channel.to_string(), account: account.to_string(), level: level.to_string() }) @@ -99,8 +107,13 @@ impl Db { /// Add an auto-kick `mask` (with `reason`) to `channel`. pub fn akick_add(&mut self, channel: &str, mask: &str, reason: &str) -> Result<(), ChanError> { let k = key(channel); - if !self.channels.contains_key(&k) { + let Some(c0) = self.channels.get(&k) else { return Err(ChanError::NoChannel); + }; + // Cap the list (and the append-only log) so an op can't grow it without + // bound; a replace of an existing mask doesn't count toward the limit. + if c0.akick.len() >= MAX_CHAN_LIST && !c0.akick.iter().any(|a| a.mask.eq_ignore_ascii_case(mask)) { + return Err(ChanError::Full); } self.log .append(Event::ChannelAkickAdd { channel: channel.to_string(), mask: mask.to_string(), reason: reason.to_string() })