Rate-limit email authentications

This commit is contained in:
Quentin Gliech
2025-01-23 12:09:26 +01:00
parent 0bca802585
commit ea6b80c5ac
10 changed files with 351 additions and 9 deletions

View File

@@ -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(),
}
}
}

View File

@@ -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<mas_policy::Violation>,
},
@@ -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)

View File

@@ -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<RequesterFingerprint>,
password_check_for_user: KeyedRateLimiter<Ulid>,
registration_per_requester: KeyedRateLimiter<RequesterFingerprint>,
email_authentication_per_requester: KeyedRateLimiter<RequesterFingerprint>,
email_authentication_per_email: KeyedRateLimiter<String>,
email_authentication_emails_per_session: KeyedRateLimiter<Ulid>,
email_authentication_attempt_per_session: KeyedRateLimiter<Ulid>,
}
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)]

View File

@@ -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

View File

@@ -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<Templates>,
State(limiter): State<Limiter>,
mut repo: BoxRepository,
cookie_jar: CookieJar,
State(url_builder): State<UrlBuilder>,
@@ -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)

View File

@@ -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",

View File

@@ -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

View File

@@ -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<{
</ErrorMessage>
)}
{status === "RATE_LIMITED" && (
<ErrorMessage>{t("frontend.errors.rate_limit_exceeded")}</ErrorMessage>
)}
{status === "DENIED" && (
<>
<ErrorMessage>

View File

@@ -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';

View File

@@ -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 (
<Layout>
@@ -133,9 +135,16 @@ function EmailVerify(): React.ReactElement {
</Alert>
)}
{rateLimited && (
<Alert
type="critical"
title={t("frontend.errors.rate_limit_exceeded")}
/>
)}
<Form.Field
name="code"
serverInvalid={invalidCode}
serverInvalid={invalidCode || rateLimited}
className="self-center mb-4"
>
<Form.Label>{t("frontend.verify_email.code_field_label")}</Form.Label>