diff --git a/crates/config/src/sections/rate_limiting.rs b/crates/config/src/sections/rate_limiting.rs index e2be8c057..9ee12fd15 100644 --- a/crates/config/src/sections/rate_limiting.rs +++ b/crates/config/src/sections/rate_limiting.rs @@ -1,4 +1,4 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only @@ -18,13 +18,19 @@ pub struct RateLimitingConfig { /// Account Recovery-specific rate limits #[serde(default)] pub account_recovery: AccountRecoveryRateLimitingConfig, + /// Login-specific rate limits #[serde(default)] pub login: LoginRateLimitingConfig, + /// Controls how many registrations attempts are permitted /// based on source address. #[serde(default = "default_registration")] pub registration: RateLimiterConfiguration, + + /// Email authentication-specific rate limits + #[serde(default)] + pub email_authentication: EmailauthenticationRateLimitingConfig, } #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] @@ -37,6 +43,7 @@ pub struct LoginRateLimitingConfig { /// change their own password. #[serde(default = "default_login_per_ip")] pub per_ip: RateLimiterConfiguration, + /// Controls how many login attempts are permitted /// based on the account that is being attempted to be logged into. /// This can protect against a distributed brute force attack @@ -58,6 +65,7 @@ pub struct AccountRecoveryRateLimitingConfig { /// Note: this limit also applies to re-sends. #[serde(default = "default_account_recovery_per_ip")] pub per_ip: RateLimiterConfiguration, + /// Controls how many account recovery attempts are permitted /// based on the e-mail address entered into the recovery form. /// This can protect against causing e-mail spam to one target. @@ -67,6 +75,35 @@ pub struct AccountRecoveryRateLimitingConfig { pub per_address: RateLimiterConfiguration, } +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] +pub struct EmailauthenticationRateLimitingConfig { + /// Controls how many email authentication attempts are permitted + /// based on the source IP address. + /// This can protect against causing e-mail spam to many targets. + #[serde(default = "default_email_authentication_per_ip")] + pub per_ip: RateLimiterConfiguration, + + /// Controls how many email authentication attempts are permitted + /// based on the e-mail address entered into the authentication form. + /// This can protect against causing e-mail spam to one target. + /// + /// Note: this limit also applies to re-sends. + #[serde(default = "default_email_authentication_per_address")] + pub per_address: RateLimiterConfiguration, + + /// Controls how many authentication emails are permitted to be sent per + /// authentication session. This ensures not too many authentication codes + /// are created for the same authentication session. + #[serde(default = "default_email_authentication_emails_per_session")] + pub emails_per_session: RateLimiterConfiguration, + + /// Controls how many code authentication attempts are permitted per + /// authentication session. This can protect against brute-forcing the + /// code. + #[serde(default = "default_email_authentication_attempt_per_session")] + pub attempt_per_session: RateLimiterConfiguration, +} + #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] pub struct RateLimiterConfiguration { /// A one-off burst of actions that the user can perform @@ -193,12 +230,41 @@ fn default_account_recovery_per_address() -> RateLimiterConfiguration { } } +fn default_email_authentication_per_ip() -> RateLimiterConfiguration { + RateLimiterConfiguration { + burst: NonZeroU32::new(5).unwrap(), + per_second: 1.0 / 60.0, + } +} + +fn default_email_authentication_per_address() -> RateLimiterConfiguration { + RateLimiterConfiguration { + burst: NonZeroU32::new(3).unwrap(), + per_second: 1.0 / 3600.0, + } +} + +fn default_email_authentication_emails_per_session() -> RateLimiterConfiguration { + RateLimiterConfiguration { + burst: NonZeroU32::new(2).unwrap(), + per_second: 1.0 / 300.0, + } +} + +fn default_email_authentication_attempt_per_session() -> RateLimiterConfiguration { + RateLimiterConfiguration { + burst: NonZeroU32::new(10).unwrap(), + per_second: 1.0 / 60.0, + } +} + impl Default for RateLimitingConfig { fn default() -> Self { RateLimitingConfig { login: LoginRateLimitingConfig::default(), registration: default_registration(), account_recovery: AccountRecoveryRateLimitingConfig::default(), + email_authentication: EmailauthenticationRateLimitingConfig::default(), } } } @@ -220,3 +286,14 @@ impl Default for AccountRecoveryRateLimitingConfig { } } } + +impl Default for EmailauthenticationRateLimitingConfig { + fn default() -> Self { + EmailauthenticationRateLimitingConfig { + per_ip: default_email_authentication_per_ip(), + per_address: default_email_authentication_per_address(), + emails_per_session: default_email_authentication_emails_per_session(), + attempt_per_session: default_email_authentication_attempt_per_session(), + } + } +} diff --git a/crates/handlers/src/graphql/mutations/user_email.rs b/crates/handlers/src/graphql/mutations/user_email.rs index afc3f7c9e..41ec2f01c 100644 --- a/crates/handlers/src/graphql/mutations/user_email.rs +++ b/crates/handlers/src/graphql/mutations/user_email.rs @@ -238,6 +238,8 @@ enum StartEmailAuthenticationStatus { Started, /// The email address is invalid InvalidEmailAddress, + /// Too many attempts to start an email authentication + RateLimited, /// The email address isn't allowed by the policy Denied, /// The email address is already in use @@ -249,6 +251,7 @@ enum StartEmailAuthenticationStatus { enum StartEmailAuthenticationPayload { Started(UserEmailAuthentication), InvalidEmailAddress, + RateLimited, Denied { violations: Vec, }, @@ -262,6 +265,7 @@ impl StartEmailAuthenticationPayload { match self { Self::Started(_) => StartEmailAuthenticationStatus::Started, Self::InvalidEmailAddress => StartEmailAuthenticationStatus::InvalidEmailAddress, + Self::RateLimited => StartEmailAuthenticationStatus::RateLimited, Self::Denied { .. } => StartEmailAuthenticationStatus::Denied, Self::InUse => StartEmailAuthenticationStatus::InUse, } @@ -271,7 +275,9 @@ impl StartEmailAuthenticationPayload { async fn authentication(&self) -> Option<&UserEmailAuthentication> { match self { Self::Started(authentication) => Some(authentication), - Self::InvalidEmailAddress | Self::Denied { .. } | Self::InUse => None, + Self::InvalidEmailAddress | Self::RateLimited | Self::Denied { .. } | Self::InUse => { + None + } } } @@ -302,6 +308,7 @@ enum CompleteEmailAuthenticationPayload { Completed, InvalidCode, CodeExpired, + RateLimited, } /// The status of the `completeEmailAuthentication` mutation @@ -313,6 +320,8 @@ enum CompleteEmailAuthenticationStatus { InvalidCode, /// The authentication code has expired CodeExpired, + /// Too many attempts to complete an email authentication + RateLimited, } #[Object(use_type_description)] @@ -323,6 +332,7 @@ impl CompleteEmailAuthenticationPayload { Self::Completed => CompleteEmailAuthenticationStatus::Completed, Self::InvalidCode => CompleteEmailAuthenticationStatus::InvalidCode, Self::CodeExpired => CompleteEmailAuthenticationStatus::CodeExpired, + Self::RateLimited => CompleteEmailAuthenticationStatus::RateLimited, } } } @@ -345,6 +355,8 @@ enum ResendEmailAuthenticationCodePayload { Resent, /// The email authentication session is already completed Completed, + /// Too many attempts to resend an email authentication code + RateLimited, } /// The status of the `resendEmailAuthenticationCode` mutation @@ -354,6 +366,8 @@ enum ResendEmailAuthenticationCodeStatus { Resent, /// The email authentication session is already completed Completed, + /// Too many attempts to resend an email authentication code + RateLimited, } #[Object(use_type_description)] @@ -363,6 +377,7 @@ impl ResendEmailAuthenticationCodePayload { match self { Self::Resent => ResendEmailAuthenticationCodeStatus::Resent, Self::Completed => ResendEmailAuthenticationCodeStatus::Completed, + Self::RateLimited => ResendEmailAuthenticationCodeStatus::RateLimited, } } } @@ -536,6 +551,7 @@ impl UserEmailMutations { let mut rng = state.rng(); let clock = state.clock(); let requester = ctx.requester(); + let limiter = state.limiter(); // Only allow calling this if the requester is a browser session let Some(browser_session) = requester.browser_session() else { @@ -563,7 +579,12 @@ impl UserEmailMutations { return Ok(StartEmailAuthenticationPayload::InvalidEmailAddress); } - // TODO: check rate limting + if let Err(e) = + limiter.check_email_authentication_email(ctx.requester_fingerprint(), &input.email) + { + tracing::warn!(error = &e as &dyn std::error::Error); + return Ok(StartEmailAuthenticationPayload::RateLimited); + } let mut repo = state.repository().await?; @@ -615,6 +636,7 @@ impl UserEmailMutations { let state = ctx.state(); let mut rng = state.rng(); let clock = state.clock(); + let limiter = state.limiter(); let id = NodeType::UserEmailAuthentication.extract_ulid(&input.id)?; let Some(browser_session) = ctx.requester().browser_session() else { @@ -647,6 +669,13 @@ impl UserEmailMutations { return Ok(ResendEmailAuthenticationCodePayload::Completed); } + if let Err(e) = limiter + .check_email_authentication_send_code(ctx.requester_fingerprint(), &authentication) + { + tracing::warn!(error = &e as &dyn std::error::Error); + return Ok(ResendEmailAuthenticationCodePayload::RateLimited); + } + repo.queue_job() .schedule_job( &mut rng, @@ -669,6 +698,7 @@ impl UserEmailMutations { let state = ctx.state(); let mut rng = state.rng(); let clock = state.clock(); + let limiter = state.limiter(); let id = NodeType::UserEmailAuthentication.extract_ulid(&input.id)?; @@ -695,6 +725,11 @@ impl UserEmailMutations { return Ok(CompleteEmailAuthenticationPayload::InvalidCode); } + if let Err(e) = limiter.check_email_authentication_attempt(&authentication) { + tracing::warn!(error = &e as &dyn std::error::Error); + return Ok(CompleteEmailAuthenticationPayload::RateLimited); + } + let Some(code) = repo .user_email() .find_authentication_code(&authentication, &input.code) diff --git a/crates/handlers/src/rate_limit.rs b/crates/handlers/src/rate_limit.rs index 23648d38e..eff30d86f 100644 --- a/crates/handlers/src/rate_limit.rs +++ b/crates/handlers/src/rate_limit.rs @@ -8,7 +8,7 @@ use std::{net::IpAddr, sync::Arc, time::Duration}; use governor::{clock::QuantaClock, state::keyed::DashMapStateStore, RateLimiter}; use mas_config::RateLimitingConfig; -use mas_data_model::User; +use mas_data_model::{User, UserEmailAuthentication}; use ulid::Ulid; #[derive(Debug, Clone, thiserror::Error)] @@ -35,6 +35,18 @@ pub enum RegistrationLimitedError { Requester(RequesterFingerprint), } +#[derive(Debug, Clone, thiserror::Error)] +pub enum EmailAuthenticationLimitedError { + #[error("Too many email authentication requests for requester {0}")] + Requester(RequesterFingerprint), + + #[error("Too many email authentication requests for authentication session {0}")] + Authentication(Ulid), + + #[error("Too many email authentication requests for email {0}")] + Email(String), +} + /// Key used to rate limit requests per requester #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct RequesterFingerprint { @@ -78,6 +90,10 @@ struct LimiterInner { password_check_for_requester: KeyedRateLimiter, password_check_for_user: KeyedRateLimiter, registration_per_requester: KeyedRateLimiter, + email_authentication_per_requester: KeyedRateLimiter, + email_authentication_per_email: KeyedRateLimiter, + email_authentication_emails_per_session: KeyedRateLimiter, + email_authentication_attempt_per_session: KeyedRateLimiter, } impl LimiterInner { @@ -92,6 +108,18 @@ impl LimiterInner { password_check_for_requester: RateLimiter::keyed(config.login.per_ip.to_quota()?), password_check_for_user: RateLimiter::keyed(config.login.per_account.to_quota()?), registration_per_requester: RateLimiter::keyed(config.registration.to_quota()?), + email_authentication_per_email: RateLimiter::keyed( + config.email_authentication.per_address.to_quota()?, + ), + email_authentication_per_requester: RateLimiter::keyed( + config.email_authentication.per_ip.to_quota()?, + ), + email_authentication_emails_per_session: RateLimiter::keyed( + config.email_authentication.emails_per_session.to_quota()?, + ), + email_authentication_attempt_per_session: RateLimiter::keyed( + config.email_authentication.attempt_per_session.to_quota()?, + ), }) } } @@ -127,6 +155,16 @@ impl Limiter { this.inner.password_check_for_requester.retain_recent(); this.inner.password_check_for_user.retain_recent(); this.inner.registration_per_requester.retain_recent(); + this.inner.email_authentication_per_email.retain_recent(); + this.inner + .email_authentication_per_requester + .retain_recent(); + this.inner + .email_authentication_emails_per_session + .retain_recent(); + this.inner + .email_authentication_attempt_per_session + .retain_recent(); interval.tick().await; } @@ -199,6 +237,66 @@ impl Limiter { Ok(()) } + + /// Check if an email can be sent to the address for an email + /// authentication session + /// + /// # Errors + /// + /// Returns an error if the operation is rate limited. + pub fn check_email_authentication_email( + &self, + requester: RequesterFingerprint, + email: &str, + ) -> Result<(), EmailAuthenticationLimitedError> { + self.inner + .email_authentication_per_requester + .check_key(&requester) + .map_err(|_| EmailAuthenticationLimitedError::Requester(requester))?; + + // Convert to lowercase to prevent bypassing the limit by enumerating different + // case variations. + // A case-folding transformation may be more proper. + let canonical_email = email.to_lowercase(); + self.inner + .email_authentication_per_email + .check_key(&canonical_email) + .map_err(|_| EmailAuthenticationLimitedError::Email(email.to_owned()))?; + Ok(()) + } + + /// Check if an attempt can be done on an email authentication session + /// + /// # Errors + /// + /// Returns an error if the operation is rate limited. + pub fn check_email_authentication_attempt( + &self, + authentication: &UserEmailAuthentication, + ) -> Result<(), EmailAuthenticationLimitedError> { + self.inner + .email_authentication_attempt_per_session + .check_key(&authentication.id) + .map_err(|_| EmailAuthenticationLimitedError::Authentication(authentication.id)) + } + + /// Check if a new authentication code can be sent for an email + /// authentication session + /// + /// # Errors + /// + /// Returns an error if the operation is rate limited. + pub fn check_email_authentication_send_code( + &self, + requester: RequesterFingerprint, + authentication: &UserEmailAuthentication, + ) -> Result<(), EmailAuthenticationLimitedError> { + self.check_email_authentication_email(requester, &authentication.email)?; + self.inner + .email_authentication_emails_per_session + .check_key(&authentication.id) + .map_err(|_| EmailAuthenticationLimitedError::Authentication(authentication.id)) + } } #[cfg(test)] diff --git a/crates/handlers/src/views/register/password.rs b/crates/handlers/src/views/register/password.rs index aebcca7c5..624b22508 100644 --- a/crates/handlers/src/views/register/password.rs +++ b/crates/handlers/src/views/register/password.rs @@ -286,6 +286,11 @@ pub(crate) async fn post( tracing::warn!(error = &e as &dyn std::error::Error); state.add_error_on_form(FormError::RateLimitExceeded); } + + if let Err(e) = limiter.check_email_authentication_email(requester, &form.email) { + tracing::warn!(error = &e as &dyn std::error::Error); + state.add_error_on_form(FormError::RateLimitExceeded); + } } state diff --git a/crates/handlers/src/views/register/steps/verify_email.rs b/crates/handlers/src/views/register/steps/verify_email.rs index 4ae18d777..9faf347e8 100644 --- a/crates/handlers/src/views/register/steps/verify_email.rs +++ b/crates/handlers/src/views/register/steps/verify_email.rs @@ -22,7 +22,7 @@ use mas_templates::{ use serde::{Deserialize, Serialize}; use ulid::Ulid; -use crate::{views::shared::OptionalPostAuthAction, PreferredLanguage}; +use crate::{views::shared::OptionalPostAuthAction, Limiter, PreferredLanguage}; #[derive(Serialize, Deserialize, Debug)] pub struct CodeForm { @@ -111,6 +111,7 @@ pub(crate) async fn post( mut rng: BoxRng, PreferredLanguage(locale): PreferredLanguage, State(templates): State, + State(limiter): State, mut repo: BoxRepository, cookie_jar: CookieJar, State(url_builder): State, @@ -157,6 +158,22 @@ pub(crate) async fn post( ))); } + if let Err(e) = limiter.check_email_authentication_attempt(&email_authentication) { + tracing::warn!(error = &e as &dyn std::error::Error); + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + let ctx = RegisterStepsVerifyEmailContext::new(email_authentication) + .with_form_state( + form.to_form_state() + .with_error_on_form(mas_templates::FormError::RateLimitExceeded), + ) + .with_csrf(csrf_token.form_value()) + .with_language(locale); + + let content = templates.render_register_steps_verify_email(&ctx)?; + + return Ok((cookie_jar, Html(content)).into_response()); + } + let Some(code) = repo .user_email() .find_authentication_code(&email_authentication, &form.code) diff --git a/docs/config.schema.json b/docs/config.schema.json index daed90d8b..fafe759d3 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -1715,6 +1715,32 @@ "$ref": "#/definitions/RateLimiterConfiguration" } ] + }, + "email_authentication": { + "description": "Email authentication-specific rate limits", + "default": { + "per_ip": { + "burst": 5, + "per_second": 0.016666666666666666 + }, + "per_address": { + "burst": 3, + "per_second": 0.0002777777777777778 + }, + "emails_per_session": { + "burst": 2, + "per_second": 0.0033333333333333335 + }, + "attempt_per_session": { + "burst": 10, + "per_second": 0.016666666666666666 + } + }, + "allOf": [ + { + "$ref": "#/definitions/EmailauthenticationRateLimitingConfig" + } + ] } } }, @@ -1796,6 +1822,59 @@ } } }, + "EmailauthenticationRateLimitingConfig": { + "type": "object", + "properties": { + "per_ip": { + "description": "Controls how many email authentication attempts are permitted based on the source IP address. This can protect against causing e-mail spam to many targets.", + "default": { + "burst": 5, + "per_second": 0.016666666666666666 + }, + "allOf": [ + { + "$ref": "#/definitions/RateLimiterConfiguration" + } + ] + }, + "per_address": { + "description": "Controls how many email authentication attempts are permitted based on the e-mail address entered into the authentication form. This can protect against causing e-mail spam to one target.\n\nNote: this limit also applies to re-sends.", + "default": { + "burst": 3, + "per_second": 0.0002777777777777778 + }, + "allOf": [ + { + "$ref": "#/definitions/RateLimiterConfiguration" + } + ] + }, + "emails_per_session": { + "description": "Controls how many authentication emails are permitted to be sent per authentication session. This ensures not too many authentication codes are created for the same authentication session.", + "default": { + "burst": 2, + "per_second": 0.0033333333333333335 + }, + "allOf": [ + { + "$ref": "#/definitions/RateLimiterConfiguration" + } + ] + }, + "attempt_per_session": { + "description": "Controls how many code authentication attempts are permitted per authentication session. This can protect against brute-forcing the code.", + "default": { + "burst": 10, + "per_second": 0.016666666666666666 + }, + "allOf": [ + { + "$ref": "#/definitions/RateLimiterConfiguration" + } + ] + } + } + }, "UpstreamOAuth2Config": { "description": "Upstream OAuth 2.0 providers configuration", "type": "object", diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 104cce890..1602835a6 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -524,6 +524,10 @@ enum CompleteEmailAuthenticationStatus { The authentication code has expired """ CODE_EXPIRED + """ + Too many attempts to complete an email authentication + """ + RATE_LIMITED } """ @@ -1265,6 +1269,10 @@ enum ResendEmailAuthenticationCodeStatus { The email authentication session is already completed """ COMPLETED + """ + Too many attempts to resend an email authentication code + """ + RATE_LIMITED } """ @@ -1634,6 +1642,10 @@ enum StartEmailAuthenticationStatus { """ INVALID_EMAIL_ADDRESS """ + Too many attempts to start an email authentication + """ + RATE_LIMITED + """ The email address isn't allowed by the policy """ DENIED diff --git a/frontend/src/components/UserProfile/AddEmailForm.tsx b/frontend/src/components/UserProfile/AddEmailForm.tsx index d96061122..6459f495e 100644 --- a/frontend/src/components/UserProfile/AddEmailForm.tsx +++ b/frontend/src/components/UserProfile/AddEmailForm.tsx @@ -1,4 +1,4 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only @@ -61,7 +61,7 @@ const AddEmailForm: React.FC<{ const formData = new FormData(e.currentTarget); const email = formData.get("input") as string; - addEmail.mutate({ email, language: i18n.languages[0] }); + await addEmail.mutateAsync({ email, language: i18n.languages[0] }); }; const status = addEmail.data?.startEmailAuthentication.status ?? null; @@ -93,6 +93,10 @@ const AddEmailForm: React.FC<{ )} + {status === "RATE_LIMITED" && ( + {t("frontend.errors.rate_limit_exceeded")} + )} + {status === "DENIED" && ( <> diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index 458c68f02..fbc5e83b2 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -348,7 +348,9 @@ export type CompleteEmailAuthenticationStatus = /** The authentication was completed */ | 'COMPLETED' /** The authentication code is invalid */ - | 'INVALID_CODE'; + | 'INVALID_CODE' + /** Too many attempts to complete an email authentication */ + | 'RATE_LIMITED'; /** The input of the `createOauth2Session` mutation. */ export type CreateOAuth2SessionInput = { @@ -972,6 +974,8 @@ export type ResendEmailAuthenticationCodePayload = { export type ResendEmailAuthenticationCodeStatus = /** The email authentication session is already completed */ | 'COMPLETED' + /** Too many attempts to resend an email authentication code */ + | 'RATE_LIMITED' /** The email was resent */ | 'RESENT'; @@ -1205,6 +1209,8 @@ export type StartEmailAuthenticationStatus = | 'INVALID_EMAIL_ADDRESS' /** The email address is already in use */ | 'IN_USE' + /** Too many attempts to start an email authentication */ + | 'RATE_LIMITED' /** The email address was started */ | 'STARTED'; diff --git a/frontend/src/routes/emails.$id.verify.lazy.tsx b/frontend/src/routes/emails.$id.verify.lazy.tsx index 2ed624088..480109ef0 100644 --- a/frontend/src/routes/emails.$id.verify.lazy.tsx +++ b/frontend/src/routes/emails.$id.verify.lazy.tsx @@ -99,6 +99,8 @@ function EmailVerify(): React.ReactElement { "RESENT"; const invalidCode = verifyEmail.data?.completeEmailAuthentication.status === "INVALID_CODE"; + const rateLimited = + verifyEmail.data?.completeEmailAuthentication.status === "RATE_LIMITED"; return ( @@ -133,9 +135,16 @@ function EmailVerify(): React.ReactElement { )} + {rateLimited && ( + + )} + {t("frontend.verify_email.code_field_label")}