Skip to content

Commit ef077d0

Browse files
committed
Rate-limit email authentications
1 parent 6092efe commit ef077d0

File tree

10 files changed

+351
-9
lines changed

10 files changed

+351
-9
lines changed

crates/config/src/sections/rate_limiting.rs

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2024 New Vector Ltd.
1+
// Copyright 2024, 2025 New Vector Ltd.
22
// Copyright 2024 The Matrix.org Foundation C.I.C.
33
//
44
// SPDX-License-Identifier: AGPL-3.0-only
@@ -18,13 +18,19 @@ pub struct RateLimitingConfig {
1818
/// Account Recovery-specific rate limits
1919
#[serde(default)]
2020
pub account_recovery: AccountRecoveryRateLimitingConfig,
21+
2122
/// Login-specific rate limits
2223
#[serde(default)]
2324
pub login: LoginRateLimitingConfig,
25+
2426
/// Controls how many registrations attempts are permitted
2527
/// based on source address.
2628
#[serde(default = "default_registration")]
2729
pub registration: RateLimiterConfiguration,
30+
31+
/// Email authentication-specific rate limits
32+
#[serde(default)]
33+
pub email_authentication: EmailauthenticationRateLimitingConfig,
2834
}
2935

3036
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
@@ -37,6 +43,7 @@ pub struct LoginRateLimitingConfig {
3743
/// change their own password.
3844
#[serde(default = "default_login_per_ip")]
3945
pub per_ip: RateLimiterConfiguration,
46+
4047
/// Controls how many login attempts are permitted
4148
/// based on the account that is being attempted to be logged into.
4249
/// This can protect against a distributed brute force attack
@@ -58,6 +65,7 @@ pub struct AccountRecoveryRateLimitingConfig {
5865
/// Note: this limit also applies to re-sends.
5966
#[serde(default = "default_account_recovery_per_ip")]
6067
pub per_ip: RateLimiterConfiguration,
68+
6169
/// Controls how many account recovery attempts are permitted
6270
/// based on the e-mail address entered into the recovery form.
6371
/// This can protect against causing e-mail spam to one target.
@@ -67,6 +75,35 @@ pub struct AccountRecoveryRateLimitingConfig {
6775
pub per_address: RateLimiterConfiguration,
6876
}
6977

78+
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
79+
pub struct EmailauthenticationRateLimitingConfig {
80+
/// Controls how many email authentication attempts are permitted
81+
/// based on the source IP address.
82+
/// This can protect against causing e-mail spam to many targets.
83+
#[serde(default = "default_email_authentication_per_ip")]
84+
pub per_ip: RateLimiterConfiguration,
85+
86+
/// Controls how many email authentication attempts are permitted
87+
/// based on the e-mail address entered into the authentication form.
88+
/// This can protect against causing e-mail spam to one target.
89+
///
90+
/// Note: this limit also applies to re-sends.
91+
#[serde(default = "default_email_authentication_per_address")]
92+
pub per_address: RateLimiterConfiguration,
93+
94+
/// Controls how many authentication emails are permitted to be sent per
95+
/// authentication session. This ensures not too many authentication codes
96+
/// are created for the same authentication session.
97+
#[serde(default = "default_email_authentication_emails_per_session")]
98+
pub emails_per_session: RateLimiterConfiguration,
99+
100+
/// Controls how many code authentication attempts are permitted per
101+
/// authentication session. This can protect against brute-forcing the
102+
/// code.
103+
#[serde(default = "default_email_authentication_attempt_per_session")]
104+
pub attempt_per_session: RateLimiterConfiguration,
105+
}
106+
70107
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
71108
pub struct RateLimiterConfiguration {
72109
/// A one-off burst of actions that the user can perform
@@ -193,12 +230,41 @@ fn default_account_recovery_per_address() -> RateLimiterConfiguration {
193230
}
194231
}
195232

233+
fn default_email_authentication_per_ip() -> RateLimiterConfiguration {
234+
RateLimiterConfiguration {
235+
burst: NonZeroU32::new(5).unwrap(),
236+
per_second: 1.0 / 60.0,
237+
}
238+
}
239+
240+
fn default_email_authentication_per_address() -> RateLimiterConfiguration {
241+
RateLimiterConfiguration {
242+
burst: NonZeroU32::new(3).unwrap(),
243+
per_second: 1.0 / 3600.0,
244+
}
245+
}
246+
247+
fn default_email_authentication_emails_per_session() -> RateLimiterConfiguration {
248+
RateLimiterConfiguration {
249+
burst: NonZeroU32::new(2).unwrap(),
250+
per_second: 1.0 / 300.0,
251+
}
252+
}
253+
254+
fn default_email_authentication_attempt_per_session() -> RateLimiterConfiguration {
255+
RateLimiterConfiguration {
256+
burst: NonZeroU32::new(10).unwrap(),
257+
per_second: 1.0 / 60.0,
258+
}
259+
}
260+
196261
impl Default for RateLimitingConfig {
197262
fn default() -> Self {
198263
RateLimitingConfig {
199264
login: LoginRateLimitingConfig::default(),
200265
registration: default_registration(),
201266
account_recovery: AccountRecoveryRateLimitingConfig::default(),
267+
email_authentication: EmailauthenticationRateLimitingConfig::default(),
202268
}
203269
}
204270
}
@@ -220,3 +286,14 @@ impl Default for AccountRecoveryRateLimitingConfig {
220286
}
221287
}
222288
}
289+
290+
impl Default for EmailauthenticationRateLimitingConfig {
291+
fn default() -> Self {
292+
EmailauthenticationRateLimitingConfig {
293+
per_ip: default_email_authentication_per_ip(),
294+
per_address: default_email_authentication_per_address(),
295+
emails_per_session: default_email_authentication_emails_per_session(),
296+
attempt_per_session: default_email_authentication_attempt_per_session(),
297+
}
298+
}
299+
}

crates/handlers/src/graphql/mutations/user_email.rs

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,8 @@ enum StartEmailAuthenticationStatus {
238238
Started,
239239
/// The email address is invalid
240240
InvalidEmailAddress,
241+
/// Too many attempts to start an email authentication
242+
RateLimited,
241243
/// The email address isn't allowed by the policy
242244
Denied,
243245
/// The email address is already in use
@@ -249,6 +251,7 @@ enum StartEmailAuthenticationStatus {
249251
enum StartEmailAuthenticationPayload {
250252
Started(UserEmailAuthentication),
251253
InvalidEmailAddress,
254+
RateLimited,
252255
Denied {
253256
violations: Vec<mas_policy::Violation>,
254257
},
@@ -262,6 +265,7 @@ impl StartEmailAuthenticationPayload {
262265
match self {
263266
Self::Started(_) => StartEmailAuthenticationStatus::Started,
264267
Self::InvalidEmailAddress => StartEmailAuthenticationStatus::InvalidEmailAddress,
268+
Self::RateLimited => StartEmailAuthenticationStatus::RateLimited,
265269
Self::Denied { .. } => StartEmailAuthenticationStatus::Denied,
266270
Self::InUse => StartEmailAuthenticationStatus::InUse,
267271
}
@@ -271,7 +275,9 @@ impl StartEmailAuthenticationPayload {
271275
async fn authentication(&self) -> Option<&UserEmailAuthentication> {
272276
match self {
273277
Self::Started(authentication) => Some(authentication),
274-
Self::InvalidEmailAddress | Self::Denied { .. } | Self::InUse => None,
278+
Self::InvalidEmailAddress | Self::RateLimited | Self::Denied { .. } | Self::InUse => {
279+
None
280+
}
275281
}
276282
}
277283

@@ -302,6 +308,7 @@ enum CompleteEmailAuthenticationPayload {
302308
Completed,
303309
InvalidCode,
304310
CodeExpired,
311+
RateLimited,
305312
}
306313

307314
/// The status of the `completeEmailAuthentication` mutation
@@ -313,6 +320,8 @@ enum CompleteEmailAuthenticationStatus {
313320
InvalidCode,
314321
/// The authentication code has expired
315322
CodeExpired,
323+
/// Too many attempts to complete an email authentication
324+
RateLimited,
316325
}
317326

318327
#[Object(use_type_description)]
@@ -323,6 +332,7 @@ impl CompleteEmailAuthenticationPayload {
323332
Self::Completed => CompleteEmailAuthenticationStatus::Completed,
324333
Self::InvalidCode => CompleteEmailAuthenticationStatus::InvalidCode,
325334
Self::CodeExpired => CompleteEmailAuthenticationStatus::CodeExpired,
335+
Self::RateLimited => CompleteEmailAuthenticationStatus::RateLimited,
326336
}
327337
}
328338
}
@@ -345,6 +355,8 @@ enum ResendEmailAuthenticationCodePayload {
345355
Resent,
346356
/// The email authentication session is already completed
347357
Completed,
358+
/// Too many attempts to resend an email authentication code
359+
RateLimited,
348360
}
349361

350362
/// The status of the `resendEmailAuthenticationCode` mutation
@@ -354,6 +366,8 @@ enum ResendEmailAuthenticationCodeStatus {
354366
Resent,
355367
/// The email authentication session is already completed
356368
Completed,
369+
/// Too many attempts to resend an email authentication code
370+
RateLimited,
357371
}
358372

359373
#[Object(use_type_description)]
@@ -363,6 +377,7 @@ impl ResendEmailAuthenticationCodePayload {
363377
match self {
364378
Self::Resent => ResendEmailAuthenticationCodeStatus::Resent,
365379
Self::Completed => ResendEmailAuthenticationCodeStatus::Completed,
380+
Self::RateLimited => ResendEmailAuthenticationCodeStatus::RateLimited,
366381
}
367382
}
368383
}
@@ -536,6 +551,7 @@ impl UserEmailMutations {
536551
let mut rng = state.rng();
537552
let clock = state.clock();
538553
let requester = ctx.requester();
554+
let limiter = state.limiter();
539555

540556
// Only allow calling this if the requester is a browser session
541557
let Some(browser_session) = requester.browser_session() else {
@@ -563,7 +579,12 @@ impl UserEmailMutations {
563579
return Ok(StartEmailAuthenticationPayload::InvalidEmailAddress);
564580
}
565581

566-
// TODO: check rate limting
582+
if let Err(e) =
583+
limiter.check_email_authentication_email(ctx.requester_fingerprint(), &input.email)
584+
{
585+
tracing::warn!(error = &e as &dyn std::error::Error);
586+
return Ok(StartEmailAuthenticationPayload::RateLimited);
587+
}
567588

568589
let mut repo = state.repository().await?;
569590

@@ -615,6 +636,7 @@ impl UserEmailMutations {
615636
let state = ctx.state();
616637
let mut rng = state.rng();
617638
let clock = state.clock();
639+
let limiter = state.limiter();
618640

619641
let id = NodeType::UserEmailAuthentication.extract_ulid(&input.id)?;
620642
let Some(browser_session) = ctx.requester().browser_session() else {
@@ -647,6 +669,13 @@ impl UserEmailMutations {
647669
return Ok(ResendEmailAuthenticationCodePayload::Completed);
648670
}
649671

672+
if let Err(e) = limiter
673+
.check_email_authentication_send_code(ctx.requester_fingerprint(), &authentication)
674+
{
675+
tracing::warn!(error = &e as &dyn std::error::Error);
676+
return Ok(ResendEmailAuthenticationCodePayload::RateLimited);
677+
}
678+
650679
repo.queue_job()
651680
.schedule_job(
652681
&mut rng,
@@ -669,6 +698,7 @@ impl UserEmailMutations {
669698
let state = ctx.state();
670699
let mut rng = state.rng();
671700
let clock = state.clock();
701+
let limiter = state.limiter();
672702

673703
let id = NodeType::UserEmailAuthentication.extract_ulid(&input.id)?;
674704

@@ -695,6 +725,11 @@ impl UserEmailMutations {
695725
return Ok(CompleteEmailAuthenticationPayload::InvalidCode);
696726
}
697727

728+
if let Err(e) = limiter.check_email_authentication_attempt(&authentication) {
729+
tracing::warn!(error = &e as &dyn std::error::Error);
730+
return Ok(CompleteEmailAuthenticationPayload::RateLimited);
731+
}
732+
698733
let Some(code) = repo
699734
.user_email()
700735
.find_authentication_code(&authentication, &input.code)

0 commit comments

Comments
 (0)