From 660362a34b0098fcdec0ac988ff4787458d23fb5 Mon Sep 17 00:00:00 2001 From: Jean Date: Sun, 19 Jul 2026 13:06:50 +0000 Subject: [PATCH] Throttle every password-verify path (GROUP/GHOST/CERT/DROP/SASL) and key the lockout by resolved account --- modules/nickserv/src/cert.rs | 33 ++++++++++++++++++++++++++------- modules/nickserv/src/drop.rs | 8 ++++++++ modules/nickserv/src/ghost.rs | 22 ++++++++++++++++++++-- modules/nickserv/src/group.rs | 10 ++++++++++ src/engine/db/account.rs | 7 +++++-- src/engine/register.rs | 3 +++ src/engine/sasl.rs | 7 +++++++ 7 files changed, 79 insertions(+), 11 deletions(-) diff --git a/modules/nickserv/src/cert.rs b/modules/nickserv/src/cert.rs index cbe7804..8aeaf21 100644 --- a/modules/nickserv/src/cert.rs +++ b/modules/nickserv/src/cert.rs @@ -5,15 +5,13 @@ use echo_api::{Sender, ServiceCtx}; // fingerprints that may log in to your account via SASL EXTERNAL. Each // subcommand is password-gated, so it needs no identified-session state. pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { - let auth = |db: &dyn Store, password: &str| db.authenticate(from.nick, password).map(str::to_string); match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() { Some("LIST") => { let Some(password) = args.get(2) else { ctx.notice(me, from.uid, "Syntax: CERT LIST "); return; }; - let Some(account) = auth(db, password) else { - ctx.notice(me, from.uid, "Invalid password."); + let Some(account) = verify(me, from, ctx, db, password) else { return; }; let fps = db.certfps(&account); @@ -28,8 +26,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: ctx.notice(me, from.uid, "Syntax: CERT ADD "); return; }; - let Some(account) = auth(db, password) else { - ctx.notice(me, from.uid, "Invalid password."); + let Some(account) = verify(me, from, ctx, db, password) else { return; }; match db.certfp_add(&account, fp) { @@ -44,8 +41,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: ctx.notice(me, from.uid, "Syntax: CERT DEL "); return; }; - let Some(account) = auth(db, password) else { - ctx.notice(me, from.uid, "Invalid password."); + let Some(account) = verify(me, from, ctx, db, password) else { return; }; match db.certfp_del(&account, fp) { @@ -57,3 +53,26 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: _ => ctx.notice(me, from.uid, "Syntax: CERT ADD|DEL|LIST [fingerprint]"), } } + +// Verify the caller's password against the account matching their current nick, +// throttled and recorded like IDENTIFY — so CERT can't be used as an unthrottled +// guessing oracle (a user can `/nick victim` then CERT LIST ) and each ~1s +// verify can't be spammed to stall the engine. +fn verify(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &mut dyn Store, password: &str) -> Option { + if let Some(secs) = db.auth_lockout(from.nick) { + ctx.notice(me, from.uid, format!("Too many failed attempts. Please wait {secs}s and try again.")); + return None; + } + match db.authenticate(from.nick, password).map(str::to_string) { + Some(account) => { + db.note_auth(from.nick, true); + Some(account) + } + None => { + db.note_auth(from.nick, false); + ctx.auth_report(false, Some(from.nick), "NickServ CERT", from.uid, Some("bad password")); + ctx.notice(me, from.uid, "Invalid password."); + None + } + } +} diff --git a/modules/nickserv/src/drop.rs b/modules/nickserv/src/drop.rs index 6fb90b7..09c7cf5 100644 --- a/modules/nickserv/src/drop.rs +++ b/modules/nickserv/src/drop.rs @@ -13,10 +13,18 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: ctx.notice(me, from.uid, "Syntax: DROP "); return; }; + // Throttle the confirmation verify like IDENTIFY so the ~1s check can't be + // spammed to stall the engine, and record the attempt. + if let Some(secs) = db.auth_lockout(account) { + ctx.notice(me, from.uid, format!("Too many failed attempts. Please wait {secs}s and try again.")); + return; + } if db.authenticate(account, password).is_none() { + db.note_auth(account, false); ctx.notice(me, from.uid, "Invalid password."); return; } + db.note_auth(account, true); // A channel with a successor is inherited; the rest are dropped and released. let (inherited, dropped) = db.release_founded_channels(account); for chan in &dropped { diff --git a/modules/nickserv/src/ghost.rs b/modules/nickserv/src/ghost.rs index 75cfd8b..93bae09 100644 --- a/modules/nickserv/src/ghost.rs +++ b/modules/nickserv/src/ghost.rs @@ -8,7 +8,7 @@ use echo_api::NetView; // it. Every argument is a distinct input the guest-rename needs, so the count is // inherent. #[allow(clippy::too_many_arguments)] -pub fn handle(me: &str, guest_nick: &str, guest_seq: &mut u32, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &dyn Store, regain: bool) { +pub fn handle(me: &str, guest_nick: &str, guest_seq: &mut u32, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store, regain: bool) { let cmd = if regain { "RECOVER" } else { "GHOST" }; let Some(&target) = args.get(1) else { ctx.notice(me, from.uid, format!("Syntax: {cmd} [password]")); @@ -18,7 +18,25 @@ pub fn handle(me: &str, guest_nick: &str, guest_seq: &mut u32, from: &Sender, ar ctx.notice(me, from.uid, format!("\x02{target}\x02 isn't registered.")); return; }; - let owns = from.account == Some(account.as_str()) || args.get(2).is_some_and(|pw| db.authenticate(target, pw).is_some()); + // Ownership by an identified session, or by password — throttled and recorded + // like IDENTIFY so GHOST can't be an unthrottled guessing oracle against any + // registered nick, and each ~1s verify can't be spammed to stall the engine. + let owns = if from.account == Some(account.as_str()) { + true + } else if let Some(&pw) = args.get(2) { + if let Some(secs) = db.auth_lockout(&account) { + ctx.notice(me, from.uid, format!("Too many failed attempts. Please wait {secs}s and try again.")); + return; + } + let ok = db.authenticate(&account, pw).is_some(); + db.note_auth(&account, ok); + if !ok { + ctx.auth_report(false, Some(&account), "NickServ GHOST", from.uid, Some("bad password")); + } + ok + } else { + false + }; if !owns { ctx.notice(me, from.uid, format!("Access denied. Identify to \x02{account}\x02 or give its password.")); return; diff --git a/modules/nickserv/src/group.rs b/modules/nickserv/src/group.rs index 17ab8d8..79a095f 100644 --- a/modules/nickserv/src/group.rs +++ b/modules/nickserv/src/group.rs @@ -8,10 +8,20 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: ctx.notice(me, from.uid, "Syntax: GROUP "); return; }; + // Throttle + record the attempt like IDENTIFY, or GROUP is an unthrottled + // password-guessing oracle against any account (and each ~1s verify blocks the + // engine). Also feeds the auth audit feed. + if let Some(secs) = db.auth_lockout(account) { + ctx.notice(me, from.uid, format!("Too many failed attempts. Please wait {secs}s and try again.")); + return; + } let Some(canonical) = db.authenticate(account, password).map(str::to_string) else { + db.note_auth(account, false); + ctx.auth_report(false, Some(account), "NickServ GROUP", from.uid, Some("bad password")); ctx.notice(me, from.uid, "Invalid account or password."); return; }; + db.note_auth(account, true); if db.account(from.nick).is_some() { ctx.notice(me, from.uid, format!("\x02{}\x02 is itself a registered account.", from.nick)); return; diff --git a/src/engine/db/account.rs b/src/engine/db/account.rs index 644cd71..9e624a4 100644 --- a/src/engine/db/account.rs +++ b/src/engine/db/account.rs @@ -302,7 +302,10 @@ impl Db { /// Seconds the caller must wait before another password attempt on `account`, /// or None if it is not currently throttled. pub fn auth_lockout(&self, account: &str) -> Option { - let until = self.auth_fails.get(&key(account))?.locked_until?; + // Key the throttle by the resolved account, so every grouped-nick alias of + // one account shares a single lockout bucket (else each spelling is its own). + let k = self.resolved_key(account).unwrap_or_else(|| key(account)); + let until = self.auth_fails.get(&k)?.locked_until?; let now = Instant::now(); (until > now).then(|| (until - now).as_secs() + 1) } @@ -311,7 +314,7 @@ impl Db { /// the counter; each failure past a few free tries grows an exponential /// backoff, throttling guessing without a hard lockout a griefer could abuse. pub fn note_auth(&mut self, account: &str, success: bool) { - let k = key(account); + let k = self.resolved_key(account).unwrap_or_else(|| key(account)); if success { self.auth_fails.remove(&k); return; diff --git a/src/engine/register.rs b/src/engine/register.rs index 3225c4f..aeeb657 100644 --- a/src/engine/register.rs +++ b/src/engine/register.rs @@ -314,6 +314,9 @@ impl Engine { actions } AuthThen::Sasl { agent, client, account } => { + // Feed the same brute-force throttle IDENTIFY uses: success clears it, + // a failure grows the backoff — so SASL isn't a throttle-free door. + self.db.note_auth(&account, ok); if ok { self.sasl_login("SASL PLAIN", &agent, &client, account) } else { diff --git a/src/engine/sasl.rs b/src/engine/sasl.rs index addfd30..a007068 100644 --- a/src/engine/sasl.rs +++ b/src/engine/sasl.rs @@ -78,6 +78,13 @@ impl Engine { }] } Some((authcid, passwd)) => match self.scram_verifier(&authcid) { + // Refuse while throttled, so SASL PLAIN can't be used to + // brute-force past the IDENTIFY lockout. + Some((account, _)) if self.db.auth_lockout(&account).is_some() => { + let mut out = mk("D", vec!["F".to_string()]); + out.extend(self.auth_report(false, Some(&account), "SASL PLAIN", &client, Some("rate limited"))); + out + } Some((account, verifier)) => vec![NetAction::DeferAuthenticate { verifier, password: passwd,