Skip to content
This repository was archived by the owner on Sep 10, 2024. It is now read-only.

Commit 5d4a4a6

Browse files
reivilibresandhose
andauthored
Add rate-limiting for account recovery and registration (#3093)
* Add rate-limiting for account recovery and registration * Rename login ratelimiter `per_address` to `per_ip` for consistency Co-authored-by: Quentin Gliech <[email protected]>
1 parent 244f8f5 commit 5d4a4a6

File tree

10 files changed

+320
-35
lines changed

10 files changed

+320
-35
lines changed

crates/config/src/sections/rate_limiting.rs

Lines changed: 86 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,21 +23,28 @@ use crate::ConfigurationSection;
2323
/// Configuration related to sending emails
2424
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
2525
pub struct RateLimitingConfig {
26+
/// Account Recovery-specific rate limits
27+
#[serde(default)]
28+
pub account_recovery: AccountRecoveryRateLimitingConfig,
2629
/// Login-specific rate limits
2730
#[serde(default)]
2831
pub login: LoginRateLimitingConfig,
32+
/// Controls how many registrations attempts are permitted
33+
/// based on source address.
34+
#[serde(default = "default_registration")]
35+
pub registration: RateLimiterConfiguration,
2936
}
3037

3138
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
3239
pub struct LoginRateLimitingConfig {
3340
/// Controls how many login attempts are permitted
34-
/// based on source address.
41+
/// based on source IP address.
3542
/// This can protect against brute force login attempts.
3643
///
3744
/// Note: this limit also applies to password checks when a user attempts to
3845
/// change their own password.
39-
#[serde(default = "default_login_per_address")]
40-
pub per_address: RateLimiterConfiguration,
46+
#[serde(default = "default_login_per_ip")]
47+
pub per_ip: RateLimiterConfiguration,
4148
/// Controls how many login attempts are permitted
4249
/// based on the account that is being attempted to be logged into.
4350
/// This can protect against a distributed brute force attack
@@ -50,6 +57,24 @@ pub struct LoginRateLimitingConfig {
5057
pub per_account: RateLimiterConfiguration,
5158
}
5259

60+
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
61+
pub struct AccountRecoveryRateLimitingConfig {
62+
/// Controls how many account recovery attempts are permitted
63+
/// based on source IP address.
64+
/// This can protect against causing e-mail spam to many targets.
65+
///
66+
/// Note: this limit also applies to re-sends.
67+
#[serde(default = "default_account_recovery_per_ip")]
68+
pub per_ip: RateLimiterConfiguration,
69+
/// Controls how many account recovery attempts are permitted
70+
/// based on the e-mail address entered into the recovery form.
71+
/// This can protect against causing e-mail spam to one target.
72+
///
73+
/// Note: this limit also applies to re-sends.
74+
#[serde(default = "default_account_recovery_per_address")]
75+
pub per_address: RateLimiterConfiguration,
76+
}
77+
5378
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
5479
pub struct RateLimiterConfiguration {
5580
/// A one-off burst of actions that the user can perform
@@ -66,6 +91,13 @@ impl ConfigurationSection for RateLimitingConfig {
6691
fn validate(&self, figment: &figment::Figment) -> Result<(), figment::Error> {
6792
let metadata = figment.find_metadata(Self::PATH.unwrap());
6893

94+
let error_on_field = |mut error: figment::error::Error, field: &'static str| {
95+
error.metadata = metadata.cloned();
96+
error.profile = Some(figment::Profile::Default);
97+
error.path = vec![Self::PATH.unwrap().to_owned(), field.to_owned()];
98+
error
99+
};
100+
69101
let error_on_nested_field =
70102
|mut error: figment::error::Error, container: &'static str, field: &'static str| {
71103
error.metadata = metadata.cloned();
@@ -92,8 +124,23 @@ impl ConfigurationSection for RateLimitingConfig {
92124
None
93125
};
94126

95-
if let Some(error) = error_on_limiter(&self.login.per_address) {
96-
return Err(error_on_nested_field(error, "login", "per_address"));
127+
if let Some(error) = error_on_limiter(&self.account_recovery.per_ip) {
128+
return Err(error_on_nested_field(error, "account_recovery", "per_ip"));
129+
}
130+
if let Some(error) = error_on_limiter(&self.account_recovery.per_address) {
131+
return Err(error_on_nested_field(
132+
error,
133+
"account_recovery",
134+
"per_address",
135+
));
136+
}
137+
138+
if let Some(error) = error_on_limiter(&self.registration) {
139+
return Err(error_on_field(error, "registration"));
140+
}
141+
142+
if let Some(error) = error_on_limiter(&self.login.per_ip) {
143+
return Err(error_on_nested_field(error, "login", "per_ip"));
97144
}
98145
if let Some(error) = error_on_limiter(&self.login.per_account) {
99146
return Err(error_on_nested_field(error, "login", "per_account"));
@@ -119,7 +166,7 @@ impl RateLimiterConfiguration {
119166
}
120167
}
121168

122-
fn default_login_per_address() -> RateLimiterConfiguration {
169+
fn default_login_per_ip() -> RateLimiterConfiguration {
123170
RateLimiterConfiguration {
124171
burst: NonZeroU32::new(3).unwrap(),
125172
per_second: 3.0 / 60.0,
@@ -133,20 +180,51 @@ fn default_login_per_account() -> RateLimiterConfiguration {
133180
}
134181
}
135182

136-
#[allow(clippy::derivable_impls)] // when we add some top-level ratelimiters this will not be derivable anymore
183+
fn default_registration() -> RateLimiterConfiguration {
184+
RateLimiterConfiguration {
185+
burst: NonZeroU32::new(3).unwrap(),
186+
per_second: 3.0 / 3600.0,
187+
}
188+
}
189+
190+
fn default_account_recovery_per_ip() -> RateLimiterConfiguration {
191+
RateLimiterConfiguration {
192+
burst: NonZeroU32::new(3).unwrap(),
193+
per_second: 3.0 / 3600.0,
194+
}
195+
}
196+
197+
fn default_account_recovery_per_address() -> RateLimiterConfiguration {
198+
RateLimiterConfiguration {
199+
burst: NonZeroU32::new(3).unwrap(),
200+
per_second: 1.0 / 3600.0,
201+
}
202+
}
203+
137204
impl Default for RateLimitingConfig {
138205
fn default() -> Self {
139206
RateLimitingConfig {
140207
login: LoginRateLimitingConfig::default(),
208+
registration: default_registration(),
209+
account_recovery: AccountRecoveryRateLimitingConfig::default(),
141210
}
142211
}
143212
}
144213

145214
impl Default for LoginRateLimitingConfig {
146215
fn default() -> Self {
147216
LoginRateLimitingConfig {
148-
per_address: default_login_per_address(),
217+
per_ip: default_login_per_ip(),
149218
per_account: default_login_per_account(),
150219
}
151220
}
152221
}
222+
223+
impl Default for AccountRecoveryRateLimitingConfig {
224+
fn default() -> Self {
225+
AccountRecoveryRateLimitingConfig {
226+
per_ip: default_account_recovery_per_ip(),
227+
per_address: default_account_recovery_per_address(),
228+
}
229+
}
230+
}

crates/handlers/src/rate_limit.rs

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,15 @@ use mas_config::RateLimitingConfig;
1919
use mas_data_model::User;
2020
use ulid::Ulid;
2121

22+
#[derive(Debug, Clone, thiserror::Error)]
23+
pub enum AccountRecoveryLimitedError {
24+
#[error("Too many account recovery requests for requester {0}")]
25+
Requester(RequesterFingerprint),
26+
27+
#[error("Too many account recovery requests for e-mail {0}")]
28+
Email(String),
29+
}
30+
2231
#[derive(Debug, Clone, Copy, thiserror::Error)]
2332
pub enum PasswordCheckLimitedError {
2433
#[error("Too many password checks for requester {0}")]
@@ -28,6 +37,12 @@ pub enum PasswordCheckLimitedError {
2837
User(Ulid),
2938
}
3039

40+
#[derive(Debug, Clone, thiserror::Error)]
41+
pub enum RegistrationLimitedError {
42+
#[error("Too many account registration requests for requester {0}")]
43+
Requester(RequesterFingerprint),
44+
}
45+
3146
/// Key used to rate limit requests per requester
3247
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3348
pub struct RequesterFingerprint {
@@ -66,15 +81,25 @@ type KeyedRateLimiter<K> = RateLimiter<K, DashMapStateStore<K>, QuantaClock>;
6681

6782
#[derive(Debug)]
6883
struct LimiterInner {
84+
account_recovery_per_requester: KeyedRateLimiter<RequesterFingerprint>,
85+
account_recovery_per_email: KeyedRateLimiter<String>,
6986
password_check_for_requester: KeyedRateLimiter<RequesterFingerprint>,
7087
password_check_for_user: KeyedRateLimiter<Ulid>,
88+
registration_per_requester: KeyedRateLimiter<RequesterFingerprint>,
7189
}
7290

7391
impl LimiterInner {
7492
fn new(config: &RateLimitingConfig) -> Option<Self> {
7593
Some(Self {
76-
password_check_for_requester: RateLimiter::keyed(config.login.per_address.to_quota()?),
94+
account_recovery_per_requester: RateLimiter::keyed(
95+
config.account_recovery.per_ip.to_quota()?,
96+
),
97+
account_recovery_per_email: RateLimiter::keyed(
98+
config.account_recovery.per_address.to_quota()?,
99+
),
100+
password_check_for_requester: RateLimiter::keyed(config.login.per_ip.to_quota()?),
77101
password_check_for_user: RateLimiter::keyed(config.login.per_account.to_quota()?),
102+
registration_per_requester: RateLimiter::keyed(config.registration.to_quota()?),
78103
})
79104
}
80105
}
@@ -105,14 +130,44 @@ impl Limiter {
105130

106131
loop {
107132
// Call the retain_recent method on each rate limiter
133+
this.inner.account_recovery_per_email.retain_recent();
134+
this.inner.account_recovery_per_requester.retain_recent();
108135
this.inner.password_check_for_requester.retain_recent();
109136
this.inner.password_check_for_user.retain_recent();
137+
this.inner.registration_per_requester.retain_recent();
110138

111139
interval.tick().await;
112140
}
113141
});
114142
}
115143

144+
/// Check if an account recovery can be performed
145+
///
146+
/// # Errors
147+
///
148+
/// Returns an error if the operation is rate limited.
149+
pub fn check_account_recovery(
150+
&self,
151+
requester: RequesterFingerprint,
152+
email_address: &str,
153+
) -> Result<(), AccountRecoveryLimitedError> {
154+
self.inner
155+
.account_recovery_per_requester
156+
.check_key(&requester)
157+
.map_err(|_| AccountRecoveryLimitedError::Requester(requester))?;
158+
159+
// Convert to lowercase to prevent bypassing the limit by enumerating different
160+
// case variations.
161+
// A case-folding transformation may be more proper.
162+
let canonical_email = email_address.to_lowercase();
163+
self.inner
164+
.account_recovery_per_email
165+
.check_key(&canonical_email)
166+
.map_err(|_| AccountRecoveryLimitedError::Email(canonical_email))?;
167+
168+
Ok(())
169+
}
170+
116171
/// Check if a password check can be performed
117172
///
118173
/// # Errors
@@ -135,6 +190,23 @@ impl Limiter {
135190

136191
Ok(())
137192
}
193+
194+
/// Check if an account registration can be performed
195+
///
196+
/// # Errors
197+
///
198+
/// Returns an error if the operation is rate limited.
199+
pub fn check_registration(
200+
&self,
201+
requester: RequesterFingerprint,
202+
) -> Result<(), RegistrationLimitedError> {
203+
self.inner
204+
.registration_per_requester
205+
.check_key(&requester)
206+
.map_err(|_| RegistrationLimitedError::Requester(requester))?;
207+
208+
Ok(())
209+
}
138210
}
139211

140212
#[cfg(test)]

crates/handlers/src/views/recovery/progress.rs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use axum::{
1717
response::{Html, IntoResponse, Response},
1818
Form,
1919
};
20+
use hyper::StatusCode;
2021
use mas_axum_utils::{
2122
cookies::CookieJar,
2223
csrf::{CsrfExt, ProtectedForm},
@@ -31,7 +32,7 @@ use mas_storage::{
3132
use mas_templates::{EmptyContext, RecoveryProgressContext, TemplateContext, Templates};
3233
use ulid::Ulid;
3334

34-
use crate::PreferredLanguage;
35+
use crate::{Limiter, PreferredLanguage, RequesterFingerprint};
3536

3637
pub(crate) async fn get(
3738
mut rng: BoxRng,
@@ -74,7 +75,7 @@ pub(crate) async fn get(
7475
return Ok((cookie_jar, Html(rendered)).into_response());
7576
}
7677

77-
let context = RecoveryProgressContext::new(recovery_session)
78+
let context = RecoveryProgressContext::new(recovery_session, false)
7879
.with_csrf(csrf_token.form_value())
7980
.with_language(locale);
8081

@@ -92,6 +93,7 @@ pub(crate) async fn post(
9293
State(site_config): State<SiteConfig>,
9394
State(templates): State<Templates>,
9495
State(url_builder): State<UrlBuilder>,
96+
(State(limiter), requester): (State<Limiter>, RequesterFingerprint),
9597
PreferredLanguage(locale): PreferredLanguage,
9698
cookie_jar: CookieJar,
9799
Path(id): Path<Ulid>,
@@ -130,14 +132,25 @@ pub(crate) async fn post(
130132
// Verify the CSRF token
131133
let () = cookie_jar.verify_form(&clock, form)?;
132134

135+
// Check the rate limit if we are about to process the form
136+
if let Err(e) = limiter.check_account_recovery(requester, &recovery_session.email) {
137+
tracing::warn!(error = &e as &dyn std::error::Error);
138+
let context = RecoveryProgressContext::new(recovery_session, true)
139+
.with_csrf(csrf_token.form_value())
140+
.with_language(locale);
141+
let rendered = templates.render_recovery_progress(&context)?;
142+
143+
return Ok((StatusCode::TOO_MANY_REQUESTS, (cookie_jar, Html(rendered))).into_response());
144+
}
145+
133146
// Schedule a new batch of emails
134147
repo.job()
135148
.schedule_job(SendAccountRecoveryEmailsJob::new(&recovery_session))
136149
.await?;
137150

138151
repo.save().await?;
139152

140-
let context = RecoveryProgressContext::new(recovery_session)
153+
let context = RecoveryProgressContext::new(recovery_session, false)
141154
.with_csrf(csrf_token.form_value())
142155
.with_language(locale);
143156

crates/handlers/src/views/recovery/start.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,12 @@ use mas_storage::{
3333
BoxClock, BoxRepository, BoxRng,
3434
};
3535
use mas_templates::{
36-
EmptyContext, FieldError, FormState, RecoveryStartContext, RecoveryStartFormField,
36+
EmptyContext, FieldError, FormError, FormState, RecoveryStartContext, RecoveryStartFormField,
3737
TemplateContext, Templates,
3838
};
3939
use serde::{Deserialize, Serialize};
4040

41-
use crate::{BoundActivityTracker, PreferredLanguage};
41+
use crate::{BoundActivityTracker, Limiter, PreferredLanguage, RequesterFingerprint};
4242

4343
#[derive(Deserialize, Serialize)]
4444
pub(crate) struct StartRecoveryForm {
@@ -90,6 +90,7 @@ pub(crate) async fn post(
9090
State(site_config): State<SiteConfig>,
9191
State(templates): State<Templates>,
9292
State(url_builder): State<UrlBuilder>,
93+
(State(limiter), requester): (State<Limiter>, RequesterFingerprint),
9394
PreferredLanguage(locale): PreferredLanguage,
9495
cookie_jar: CookieJar,
9596
Form(form): Form<ProtectedForm<StartRecoveryForm>>,
@@ -120,6 +121,14 @@ pub(crate) async fn post(
120121
form_state.with_error_on_field(RecoveryStartFormField::Email, FieldError::Invalid);
121122
}
122123

124+
if form_state.is_valid() {
125+
// Check the rate limit if we are about to process the form
126+
if let Err(e) = limiter.check_account_recovery(requester, &form.email) {
127+
tracing::warn!(error = &e as &dyn std::error::Error);
128+
form_state.add_error_on_form(FormError::RateLimitExceeded);
129+
}
130+
}
131+
123132
if !form_state.is_valid() {
124133
repo.save().await?;
125134
let context = RecoveryStartContext::new()

0 commit comments

Comments
 (0)