Cap certfp, channel access, and akick lists to bound attacker-driven log growth

This commit is contained in:
Jean Chevronnet 2026-07-19 13:27:50 +00:00
parent d833dc28f0
commit 64c4bbeae4
No known key found for this signature in database
4 changed files with 25 additions and 3 deletions

View file

@ -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);

View file

@ -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() })