services: throttle emailed codes to stop RESEND/RESETPASS email-bombing
All checks were successful
CI / check (push) Successful in 3m46s
All checks were successful
CI / check (push) Successful in 3m46s
REGISTER was rate-limited but RESEND and RESETPASS weren't — anyone could repeatedly request confirmation/reset codes for any account with an address on file, flooding the victim's inbox and burning the service's sender reputation. A per-account 60s cooldown (code_issue_wait, mirroring the vhost request throttle) now gates both paths; guessing was already infeasible (2^40 code + 5-try limit), so this closes the abuse, not an auth hole.
This commit is contained in:
parent
320a591ae3
commit
61005f52f5
7 changed files with 44 additions and 1 deletions
|
|
@ -953,6 +953,8 @@ pub trait Store {
|
||||||
fn vhost_owner(&self, host: &str) -> Option<String>;
|
fn vhost_owner(&self, host: &str) -> Option<String>;
|
||||||
fn request_vhost(&mut self, account: &str, host: &str) -> Result<(), RegError>;
|
fn request_vhost(&mut self, account: &str, host: &str) -> Result<(), RegError>;
|
||||||
fn vhost_request_wait(&self, account: &str) -> u64;
|
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<Option<String>, RegError>;
|
fn take_vhost_request(&mut self, account: &str) -> Result<Option<String>, RegError>;
|
||||||
fn vhost_requests(&self) -> Vec<(String, String)>;
|
fn vhost_requests(&self) -> Vec<(String, String)>;
|
||||||
fn vhost_offer_add(&mut self, host: &str) -> Result<bool, RegError>;
|
fn vhost_offer_add(&mut self, host: &str) -> Result<bool, RegError>;
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
ctx.notice(me, from.uid, "That account has no email on file, so it can't be reset.");
|
||||||
return;
|
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 code = db.issue_code(&canonical, CodeKind::Reset);
|
||||||
let mail = echo_api::email::reset(db.email_brand(), db.email_accent(), db.email_logo(), &canonical, &code);
|
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));
|
ctx.send_email(email, mail.subject, mail.text, Some(mail.html));
|
||||||
|
|
|
||||||
|
|
@ -177,11 +177,20 @@ impl Db {
|
||||||
let code = gen_code();
|
let code = gen_code();
|
||||||
self.codes.insert(
|
self.codes.insert(
|
||||||
key(account),
|
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
|
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
|
/// 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
|
/// 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.
|
/// they run out, so it can't be ground down online.
|
||||||
|
|
|
||||||
|
|
@ -983,6 +983,7 @@ pub struct HostConfig {
|
||||||
struct PendingCode {
|
struct PendingCode {
|
||||||
kind: CodeKind,
|
kind: CodeKind,
|
||||||
code: String,
|
code: String,
|
||||||
|
issued: Instant,
|
||||||
deadline: Instant,
|
deadline: Instant,
|
||||||
tries_left: u8,
|
tries_left: u8,
|
||||||
}
|
}
|
||||||
|
|
@ -996,6 +997,10 @@ struct AuthThrottle {
|
||||||
// Wrong-code guesses tolerated before a code is invalidated (defence in depth on
|
// Wrong-code guesses tolerated before a code is invalidated (defence in depth on
|
||||||
// top of the code's own entropy).
|
// top of the code's own entropy).
|
||||||
const CODE_TRIES: u8 = 5;
|
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.
|
// Free password attempts before the exponential backoff kicks in, and its cap.
|
||||||
const AUTH_FREE_TRIES: u32 = 3;
|
const AUTH_FREE_TRIES: u32 = 3;
|
||||||
const AUTH_MAX_BACKOFF_SECS: u64 = 300;
|
const AUTH_MAX_BACKOFF_SECS: u64 = 300;
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,9 @@ impl Store for Db {
|
||||||
fn vhost_request_wait(&self, account: &str) -> u64 {
|
fn vhost_request_wait(&self, account: &str) -> u64 {
|
||||||
Db::vhost_request_wait(self, account)
|
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<Option<String>, RegError> {
|
fn take_vhost_request(&mut self, account: &str) -> Result<Option<String>, RegError> {
|
||||||
Db::take_vhost_request(self, account)
|
Db::take_vhost_request(self, account)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,9 @@ impl Engine {
|
||||||
None => resp("error", "ACCOUNT_UNKNOWN", "No such account."),
|
None => resp("error", "ACCOUNT_UNKNOWN", "No such account."),
|
||||||
Some((true, _)) => resp("error", "ALREADY_VERIFIED", "That account is already verified."),
|
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, 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))) => {
|
Some((false, Some(addr))) => {
|
||||||
let code = self.db.issue_code(&account, db::CodeKind::Confirm);
|
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);
|
let mail = echo_api::email::confirm(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), &account, &code);
|
||||||
|
|
|
||||||
|
|
@ -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"))));
|
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
|
// With email configured, registering with an address creates an unverified
|
||||||
// account and emails a confirmation code; CONFIRM <code> verifies it.
|
// account and emails a confirmation code; CONFIRM <code> verifies it.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue