diff --git a/Cargo.lock b/Cargo.lock index 006fd2fa5..80f3e0567 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/Cargo.toml b/Cargo.toml index 8170dc86f..c899120b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 # diff --git a/src/service/Cargo.toml b/src/service/Cargo.toml index 533def8f2..a1cb8751a 100644 --- a/src/service/Cargo.toml +++ b/src/service/Cargo.toml @@ -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 diff --git a/src/service/threepid/mod.rs b/src/service/threepid/mod.rs index 2c074bcde..50ea1413c 100644 --- a/src/service/threepid/mod.rs +++ b/src/service/threepid/mod.rs @@ -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, send_attempts: std::sync::Mutex>, + ratelimiter: DefaultKeyedRateLimiter
, } 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.