feat: Ratelimit sending threepid validation emails

This commit is contained in:
Ginger
2026-03-27 12:21:58 -04:00
committed by Ellis Git
parent d5675b85cf
commit b6f0b41d3d
4 changed files with 97 additions and 23 deletions
Generated
+54 -2
View File
@@ -1172,6 +1172,7 @@ dependencies = [
"const-str",
"either",
"futures",
"governor",
"hickory-resolver",
"http",
"image",
@@ -1182,6 +1183,7 @@ dependencies = [
"log",
"loole",
"lru-cache",
"nonzero_ext",
"rand 0.10.0",
"recaptcha-verify",
"regex",
@@ -2003,6 +2005,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -2119,6 +2127,12 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-timer"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
[[package]]
name = "futures-util"
version = "0.3.32"
@@ -2209,6 +2223,25 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "governor"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8"
dependencies = [
"cfg-if",
"futures-sink",
"futures-timer",
"futures-util",
"hashbrown 0.16.1",
"nonzero_ext",
"parking_lot",
"portable-atomic",
"smallvec",
"spinning_top",
"web-time",
]
[[package]]
name = "h2"
version = "0.4.13"
@@ -2289,7 +2322,7 @@ version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
"foldhash 0.1.5",
]
[[package]]
@@ -2297,6 +2330,11 @@ name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.2.0",
]
[[package]]
name = "hdrhistogram"
@@ -3367,6 +3405,12 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7"
[[package]]
name = "nonzero_ext"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
[[package]]
name = "noop_proc_macro"
version = "0.3.0"
@@ -5357,6 +5401,15 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "spinning_top"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
dependencies = [
"lock_api",
]
[[package]]
name = "spki"
version = "0.7.3"
@@ -5383,7 +5436,6 @@ dependencies = [
"cfg-if",
"libc",
"psm",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
+8
View File
@@ -561,6 +561,14 @@ version = "0.11.19"
default-features = false
features = ["smtp-transport", "pool", "hostname", "builder", "rustls", "aws-lc-rs", "rustls-native-certs", "tokio1", "tokio1-rustls", "tracing", "serde"]
[workspace.dependencies.governor]
version = "0.10.4"
default-features = false
features = ["std"]
[workspace.dependencies.nonzero_ext]
version = "0.3.0"
#
# Patches
#
+2
View File
@@ -91,6 +91,7 @@ conduwuit-database.workspace = true
const-str.workspace = true
either.workspace = true
futures.workspace = true
governor.workspace = true
hickory-resolver.workspace = true
http.workspace = true
image.workspace = true
@@ -102,6 +103,7 @@ ldap3.optional = true
log.workspace = true
loole.workspace = true
lru-cache.workspace = true
nonzero_ext.workspace = true
rand.workspace = true
regex.workspace = true
reqwest.workspace = true
+33 -21
View File
@@ -1,9 +1,13 @@
use std::{borrow::Cow, collections::HashMap, sync::Arc};
use conduwuit::{Err, Result, result::FlatOk};
use conduwuit::{Err, Error, Result, result::FlatOk};
use database::{Deserialized, Map};
use governor::{DefaultKeyedRateLimiter, Quota, RateLimiter};
use lettre::{Address, message::Mailbox};
use ruma::{ClientSecret, OwnedClientSecret, OwnedSessionId, SessionId};
use nonzero_ext::nonzero;
use ruma::{
ClientSecret, OwnedClientSecret, OwnedSessionId, SessionId, api::client::error::ErrorKind,
};
mod session;
@@ -18,6 +22,7 @@ pub struct Service {
services: Services,
sessions: tokio::sync::Mutex<ValidationSessions>,
send_attempts: std::sync::Mutex<HashMap<(OwnedClientSecret, Address), usize>>,
ratelimiter: DefaultKeyedRateLimiter<Address>,
}
struct Data {
@@ -43,6 +48,7 @@ impl crate::Service for Service {
},
sessions: tokio::sync::Mutex::default(),
send_attempts: std::sync::Mutex::default(),
ratelimiter: RateLimiter::keyed(Self::EMAIL_RATELIMIT),
}))
}
@@ -50,6 +56,12 @@ impl crate::Service for Service {
}
impl Service {
// Each address gets two tickets to send an email, which refill at a rate of one
// per ten minutes. This allows two emails to be sent at once without waiting
// (in case the first one gets eaten), but requires a wait of at least ten
// minutes before sending another.
const EMAIL_RATELIMIT: Quota =
Quota::per_minute(nonzero!(10_u32)).allow_burst(nonzero!(2_u32));
const VALIDATION_URL_PATH: &str = "/_continuwuity/3pid/email/validate";
/// Send a validation message to an email address.
@@ -76,29 +88,29 @@ impl Service {
return Ok(session.session_id.clone());
},
| ValidationState::Pending(ref mut token) => {
// Check the send attempt for this session
// Check ratelimiting for the target address.
if self.ratelimiter.check_key(&recipient.email).is_err() {
return Err(Error::BadRequest(
ErrorKind::LimitExceeded { retry_after: None },
"You're sending emails too fast, try again in a few minutes.",
));
}
// Check the send attempt for this session.
let mut send_attempts = self.send_attempts.lock().unwrap();
match send_attempts
.get_mut(&(session.client_secret.clone(), session.email.clone()))
{
| Some(last_send_attempt) => {
if send_attempt <= *last_send_attempt {
// If the supplied send attempt isn't higher than the last
// one, don't send an email.
return Ok(session.session_id.clone());
}
let last_send_attempt = send_attempts
.entry((session.client_secret.clone(), session.email.clone()))
.or_default();
// Otherwise save the supplied send attempt.
*last_send_attempt = send_attempt;
},
| None => {
// Default to sending an email if no previous
// attempt could be found. This can happen if
// the server was restarted, which clears the
// send attempt tracker.
},
if send_attempt <= *last_send_attempt {
// If the supplied send attempt isn't higher than the last
// one, don't send an email.
return Ok(session.session_id.clone());
}
// Save this send attempt.
*last_send_attempt = send_attempt;
drop(send_attempts);
// Create a new token for the existing session.