diff --git a/api/src/lib.rs b/api/src/lib.rs index a07bf76..69f0f39 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -953,6 +953,8 @@ pub trait Store { fn vhost_owner(&self, host: &str) -> Option; fn request_vhost(&mut self, account: &str, host: &str) -> Result<(), RegError>; fn vhost_request_wait(&self, account: &str) -> u64; + // Seconds before another emailed code may be issued for this account (0 = now). + fn code_issue_wait(&self, account: &str) -> u64; fn take_vhost_request(&mut self, account: &str) -> Result, RegError>; fn vhost_requests(&self) -> Vec<(String, String)>; fn vhost_offer_add(&mut self, host: &str) -> Result; diff --git a/modules/nickserv/src/resetpass.rs b/modules/nickserv/src/resetpass.rs index 1af89a6..8ffa91d 100644 --- a/modules/nickserv/src/resetpass.rs +++ b/modules/nickserv/src/resetpass.rs @@ -18,6 +18,11 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: ctx.notice(me, from.uid, "That account has no email on file, so it can't be reset."); return; }; + let wait = db.code_issue_wait(&canonical); + if wait > 0 { + ctx.notice(me, from.uid, format!("A code was just sent — please wait \x02{wait}\x02s before requesting another.")); + return; + } let code = db.issue_code(&canonical, CodeKind::Reset); let mail = echo_api::email::reset(db.email_brand(), db.email_accent(), db.email_logo(), &canonical, &code); ctx.send_email(email, mail.subject, mail.text, Some(mail.html)); diff --git a/src/engine/db/account.rs b/src/engine/db/account.rs index 98d49b4..6947612 100644 --- a/src/engine/db/account.rs +++ b/src/engine/db/account.rs @@ -177,11 +177,20 @@ impl Db { let code = gen_code(); self.codes.insert( key(account), - PendingCode { kind, code: code.clone(), deadline: Instant::now() + Duration::from_secs(900), tries_left: CODE_TRIES }, + PendingCode { kind, code: code.clone(), issued: Instant::now(), deadline: Instant::now() + Duration::from_secs(900), tries_left: CODE_TRIES }, ); code } + /// Seconds the caller must wait before another emailed code for `account` + /// (0 = allowed now). Throttles RESEND/RESETPASS against email-bombing. + pub fn code_issue_wait(&self, account: &str) -> u64 { + self.codes.get(&key(account)).map_or(0, |pc| { + let ready = pc.issued + CODE_ISSUE_COOLDOWN; + ready.checked_duration_since(Instant::now()).map_or(0, |d| d.as_secs() + 1) + }) + } + /// Consume a code for `account`: true if the purpose and code match and it /// hasn't expired. A wrong guess burns a try and the code is dropped once /// they run out, so it can't be ground down online. diff --git a/src/engine/db/mod.rs b/src/engine/db/mod.rs index 985f47f..f55bef9 100644 --- a/src/engine/db/mod.rs +++ b/src/engine/db/mod.rs @@ -983,6 +983,7 @@ pub struct HostConfig { struct PendingCode { kind: CodeKind, code: String, + issued: Instant, deadline: Instant, tries_left: u8, } @@ -996,6 +997,10 @@ struct AuthThrottle { // Wrong-code guesses tolerated before a code is invalidated (defence in depth on // top of the code's own entropy). const CODE_TRIES: u8 = 5; + +// Minimum gap between emailed codes for one account, so RESEND/RESETPASS can't be +// used to email-bomb an address (or burn the service's sender reputation). +const CODE_ISSUE_COOLDOWN: Duration = Duration::from_secs(60); // Free password attempts before the exponential backoff kicks in, and its cap. const AUTH_FREE_TRIES: u32 = 3; const AUTH_MAX_BACKOFF_SECS: u64 = 300; diff --git a/src/engine/db/store.rs b/src/engine/db/store.rs index b808f9a..b2af07a 100644 --- a/src/engine/db/store.rs +++ b/src/engine/db/store.rs @@ -125,6 +125,9 @@ impl Store for Db { fn vhost_request_wait(&self, account: &str) -> u64 { Db::vhost_request_wait(self, account) } + fn code_issue_wait(&self, account: &str) -> u64 { + Db::code_issue_wait(self, account) + } fn take_vhost_request(&mut self, account: &str) -> Result, RegError> { Db::take_vhost_request(self, account) } diff --git a/src/engine/register.rs b/src/engine/register.rs index fc07802..390f113 100644 --- a/src/engine/register.rs +++ b/src/engine/register.rs @@ -175,6 +175,9 @@ impl Engine { None => resp("error", "ACCOUNT_UNKNOWN", "No such account."), Some((true, _)) => resp("error", "ALREADY_VERIFIED", "That account is already verified."), Some((false, None)) => resp("error", "NO_EMAIL", "No email address is on file for that account."), + Some((false, Some(_))) if self.db.code_issue_wait(&account) > 0 => { + resp("error", "TEMPORARILY_UNAVAILABLE", "A code was just sent — please wait a moment before requesting another.") + } Some((false, Some(addr))) => { let code = self.db.issue_code(&account, db::CodeKind::Confirm); let mail = echo_api::email::confirm(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), &account, &code); diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 6a1e182..18668eb 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -808,6 +808,22 @@ assert!(to_ns(&mut e, "RESETPASS alice 000000 x").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Invalid or expired")))); } + // RESETPASS is throttled per account so it can't be used to email-bomb the + // address on file: a second request inside the cooldown sends no email. + #[test] + fn resetpass_is_throttled_against_email_bombing() { + let mut e = engine_with("nsresetrl", "alice", "sesame"); + e.db.set_email_enabled(true); + e.db.set_email("alice", Some("alice@example.org".into())).unwrap(); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "someone".into(), host: "h".into() , ip: "0.0.0.0".into() }); + let to_ns = |e: &mut Engine, text: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: text.into() }); + + assert!(to_ns(&mut e, "RESETPASS alice").iter().any(|a| matches!(a, NetAction::SendEmail { .. })), "first request emails a code"); + let out = to_ns(&mut e, "RESETPASS alice"); + assert!(!out.iter().any(|a| matches!(a, NetAction::SendEmail { .. })), "second immediate request sends no email: {out:?}"); + assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("wait"))), "and tells the caller to wait: {out:?}"); + } + // With email configured, registering with an address creates an unverified // account and emails a confirmation code; CONFIRM verifies it. #[test]