Cap certfp, channel access, and akick lists to bound attacker-driven log growth
This commit is contained in:
parent
d833dc28f0
commit
64c4bbeae4
4 changed files with 25 additions and 3 deletions
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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."),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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() })
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue