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

@ -1858,6 +1858,7 @@ pub enum CertError {
Invalid, // not a plausible fingerprint Invalid, // not a plausible fingerprint
InUse, // already registered (to any account) InUse, // already registered (to any account)
NoAccount, // target account does not exist NoAccount, // target account does not exist
Full, // the account's fingerprint list is at its cap
Internal, // persistence failed Internal, // persistence failed
} }
@ -1866,6 +1867,7 @@ pub enum ChanError {
Exists, // channel already registered Exists, // channel already registered
NoChannel, // channel is not registered NoChannel, // channel is not registered
InvalidPattern, // a badword regex didn't compile InvalidPattern, // a badword regex didn't compile
Full, // the list (access / akick) is at its cap
Internal, // persistence failed Internal, // persistence failed
} }

View file

@ -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.")), 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::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::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."), Err(CertError::NoAccount | CertError::Internal) => ctx.notice(me, from.uid, "Could not add the fingerprint, please try again later."),
} }
} }

View file

@ -632,8 +632,14 @@ impl Db {
return Err(CertError::InUse); return Err(CertError::InUse);
} }
let k = key(account); let k = key(account);
if !self.accounts.contains_key(&k) { let Some(acct) = self.accounts.get(&k) else {
return Err(CertError::NoAccount); 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.log.append(Event::CertAdded { account: account.to_string(), fp: fp.clone() }).map_err(|_| CertError::Internal)?;
self.accounts.get_mut(&k).unwrap().certfps.push(fp); self.accounts.get_mut(&k).unwrap().certfps.push(fp);

View file

@ -1,5 +1,9 @@
use super::*; 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 { impl Db {
/// Register `name` to `founder` (an account name). /// Register `name` to `founder` (an account name).
pub fn register_channel(&mut self, name: &str, founder: &str) -> Result<(), ChanError> { 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`. /// Grant `account` a level ("op"/"voice") on `channel`.
pub fn access_add(&mut self, channel: &str, account: &str, level: &str) -> Result<(), ChanError> { pub fn access_add(&mut self, channel: &str, account: &str, level: &str) -> Result<(), ChanError> {
let k = key(channel); let k = key(channel);
if !self.channels.contains_key(&k) { let Some(c0) = self.channels.get(&k) else {
return Err(ChanError::NoChannel); 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 self.log
.append(Event::ChannelAccessAdd { channel: channel.to_string(), account: account.to_string(), level: level.to_string() }) .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`. /// Add an auto-kick `mask` (with `reason`) to `channel`.
pub fn akick_add(&mut self, channel: &str, mask: &str, reason: &str) -> Result<(), ChanError> { pub fn akick_add(&mut self, channel: &str, mask: &str, reason: &str) -> Result<(), ChanError> {
let k = key(channel); let k = key(channel);
if !self.channels.contains_key(&k) { let Some(c0) = self.channels.get(&k) else {
return Err(ChanError::NoChannel); 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 self.log
.append(Event::ChannelAkickAdd { channel: channel.to_string(), mask: mask.to_string(), reason: reason.to_string() }) .append(Event::ChannelAkickAdd { channel: channel.to_string(), mask: mask.to_string(), reason: reason.to_string() })