From 5529264c3f35215e58758c25c9682e9ef38957ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Wed, 22 Dec 2021 21:48:49 +0100 Subject: [PATCH 1/2] Basic ratelimit for user login (including 2FA) and admin login --- Cargo.lock | 79 +++++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 1 + src/api/admin.rs | 4 +++ src/api/identity.rs | 3 ++ src/config.rs | 10 ++++++ src/main.rs | 1 + src/ratelimit.rs | 38 ++++++++++++++++++++++ 7 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 src/ratelimit.rs diff --git a/Cargo.lock b/Cargo.lock index df139857..8d5d1b68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "ahash" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8fd72866655d1904d6b0997d0b07ba561047d070fbe29de039031c641b61217" + [[package]] name = "aho-corasick" version = "0.7.18" @@ -412,6 +418,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "dashmap" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e77a43b28d0668df09411cb0bc9a8c2adc40f9a048afe863e05fd43251e8e39c" +dependencies = [ + "cfg-if 1.0.0", + "num_cpus", +] + [[package]] name = "data-encoding" version = "2.3.2" @@ -731,6 +747,12 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dabf1872aaab32c886832f2276d2f5399887e2bd613698a02359e4ea83f8de12" +[[package]] +name = "futures-timer" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64b03909df88034c26dc1547e8970b91f98bdb65165d6a4e9110d94263dbb2c" + [[package]] name = "futures-util" version = "0.3.18" @@ -802,6 +824,23 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574" +[[package]] +name = "governor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06c5d2f987ee8f6dff3fa1a352058dc59b990e447e4c7846aa7d804971314f7b" +dependencies = [ + "dashmap", + "futures", + "futures-timer", + "no-std-compat", + "nonzero_ext", + "parking_lot 0.11.2", + "quanta", + "rand 0.8.4", + "smallvec 1.7.0", +] + [[package]] name = "h2" version = "0.3.7" @@ -842,6 +881,16 @@ dependencies = [ "walkdir", ] +[[package]] +name = "hashbrown" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91b62f79061a0bc2e046024cb7ba44b08419ed238ecbd9adbd787434b9e8c25" +dependencies = [ + "ahash", + "autocfg", +] + [[package]] name = "hashbrown" version = "0.11.2" @@ -1042,7 +1091,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc633605454125dec4b66843673f01c7df2b89479b32e0ed634e43a91cff62a5" dependencies = [ "autocfg", - "hashbrown", + "hashbrown 0.11.2", ] [[package]] @@ -1480,6 +1529,15 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4a24736216ec316047a1fc4252e27dabb04218aa4a3f37c6e7ddbf1f9782b54" +[[package]] +name = "no-std-compat" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" +dependencies = [ + "hashbrown 0.8.2", +] + [[package]] name = "nom" version = "4.1.1" @@ -1500,6 +1558,12 @@ dependencies = [ "version_check 0.9.3", ] +[[package]] +name = "nonzero_ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44a1290799eababa63ea60af0cbc3f03363e328e58f32fb0294798ed3e85f444" + [[package]] name = "ntapi" version = "0.3.6" @@ -1966,11 +2030,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "292972edad6bbecc137ab84c5e36421a4a6c979ea31d3cc73540dd04315b33e1" dependencies = [ "byteorder", - "hashbrown", + "hashbrown 0.11.2", "idna 0.2.3", "psl-types", ] +[[package]] +name = "quanta" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98dc777a7a39b76b1a26ae9d3f691f4c1bc0455090aa0b64dfa8cb7fc34c135" +dependencies = [ + "libc", + "winapi 0.3.9", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -3213,6 +3287,7 @@ dependencies = [ "diesel_migrations", "dotenv", "fern", + "governor", "handlebars", "html5ever", "idna 0.2.3", diff --git a/Cargo.toml b/Cargo.toml index 5d4617ce..5f8e16b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -139,6 +139,7 @@ backtrace = "0.3.63" # Macro ident concatenation paste = "1.0.6" +governor = "0.3.2" [patch.crates-io] # Use newest ring diff --git a/src/api/admin.rs b/src/api/admin.rs index 74fd6d8a..60f6aad4 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -166,6 +166,10 @@ fn post_admin_login( ) -> Result> { let data = data.into_inner(); + if crate::ratelimit::check_limit_admin(&ip.ip).is_err() { + return Err(Flash::error(Redirect::to(admin_url(referer)), "Too many requests, try again later.")); + } + // If the token is invalid, redirect to login page if !_validate_token(&data.token) { error!("Invalid admin token. IP: {}", ip.ip); diff --git a/src/api/identity.rs b/src/api/identity.rs index 356364b1..3cb26ba3 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -84,6 +84,9 @@ fn _password_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> JsonResult err!("Scope not supported") } + // Ratelimit the login + crate::ratelimit::check_limit_login(&ip.ip)?; + // Get the user let username = data.username.as_ref().unwrap(); let user = match User::find_by_mail(username, &conn) { diff --git a/src/config.rs b/src/config.rs index 9639b3c4..7312a6c2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -511,6 +511,16 @@ make_config! { /// Allowed iframe ancestors (Know the risks!) |> Allows other domains to embed the web vault into an iframe, useful for embedding into secure intranets allowed_iframe_ancestors: String, true, def, String::new(); + + /// Seconds between login requests |> Number of seconds, on average, between login requests before rate limiting kicks in. Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2 + login_ratelimit_seconds: u64, false, def, 60; + /// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `login_ratelimit_seconds` + login_ratelimit_max_burst: u32, false, def, 10; + + /// Seconds between admin requests |> Number of seconds, on average, between admin requests before rate limiting kicks in + admin_ratelimit_seconds: u64, false, def, 300; + /// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `admin_ratelimit_seconds` + admin_ratelimit_max_burst: u32, false, def, 3; }, /// Yubikey settings diff --git a/src/main.rs b/src/main.rs index e23b2e4c..dd9fa51e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,6 +32,7 @@ mod crypto; #[macro_use] mod db; mod mail; +mod ratelimit; mod util; pub use config::CONFIG; diff --git a/src/ratelimit.rs b/src/ratelimit.rs new file mode 100644 index 00000000..c85ce7ad --- /dev/null +++ b/src/ratelimit.rs @@ -0,0 +1,38 @@ +use once_cell::sync::Lazy; +use std::{net::IpAddr, num::NonZeroU32, time::Duration}; + +use governor::{clock::DefaultClock, state::keyed::DashMapStateStore, Quota, RateLimiter}; + +use crate::{Error, CONFIG}; + +type Limiter = RateLimiter, DefaultClock>; + +static LIMITER_LOGIN: Lazy = Lazy::new(|| { + let seconds = Duration::from_secs(CONFIG.login_ratelimit_seconds()); + let burst = NonZeroU32::new(CONFIG.login_ratelimit_max_burst()).expect("Non-zero login ratelimit burst"); + RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero login ratelimit seconds").allow_burst(burst)) +}); + +static LIMITER_ADMIN: Lazy = Lazy::new(|| { + let seconds = Duration::from_secs(CONFIG.admin_ratelimit_seconds()); + let burst = NonZeroU32::new(CONFIG.admin_ratelimit_max_burst()).expect("Non-zero admin ratelimit burst"); + RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero admin ratelimit seconds").allow_burst(burst)) +}); + +pub fn check_limit_login(ip: &IpAddr) -> Result<(), Error> { + match LIMITER_LOGIN.check_key(ip) { + Ok(_) => Ok(()), + Err(_e) => { + err_code!("Too many login requests", 429); + } + } +} + +pub fn check_limit_admin(ip: &IpAddr) -> Result<(), Error> { + match LIMITER_ADMIN.check_key(ip) { + Ok(_) => Ok(()), + Err(_e) => { + err_code!("Too many admin requests", 429); + } + } +} From d4eb21c2d9735e05041ecfc984974aaaec941123 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Sat, 25 Dec 2021 01:10:21 +0100 Subject: [PATCH 2/2] Better document the new rate limiting --- .env.template | 11 +++++++++++ src/config.rs | 6 +++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.env.template b/.env.template index ca6962b4..7fcbbfcb 100644 --- a/.env.template +++ b/.env.template @@ -268,6 +268,17 @@ ## Multiple values must be separated with a whitespace. # ALLOWED_IFRAME_ANCESTORS= +## Number of seconds, on average, between login requests from the same IP address before rate limiting kicks in. +# LOGIN_RATELIMIT_SECONDS=60 +## Allow a burst of requests of up to this size, while maintaining the average indicated by `LOGIN_RATELIMIT_SECONDS`. +## Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2. +# LOGIN_RATELIMIT_MAX_BURST=10 + +## Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in. +# ADMIN_RATELIMIT_SECONDS=300 +## Allow a burst of requests of up to this size, while maintaining the average indicated by `ADMIN_RATELIMIT_SECONDS`. +# ADMIN_RATELIMIT_MAX_BURST=3 + ## Yubico (Yubikey) Settings ## Set your Client ID and Secret Key for Yubikey OTP ## You can generate it here: https://upgrade.yubico.com/getapikey/ diff --git a/src/config.rs b/src/config.rs index 7312a6c2..5bbe8575 100644 --- a/src/config.rs +++ b/src/config.rs @@ -512,12 +512,12 @@ make_config! { /// Allowed iframe ancestors (Know the risks!) |> Allows other domains to embed the web vault into an iframe, useful for embedding into secure intranets allowed_iframe_ancestors: String, true, def, String::new(); - /// Seconds between login requests |> Number of seconds, on average, between login requests before rate limiting kicks in. Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2 + /// Seconds between login requests |> Number of seconds, on average, between login and 2FA requests from the same IP address before rate limiting kicks in login_ratelimit_seconds: u64, false, def, 60; - /// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `login_ratelimit_seconds` + /// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `login_ratelimit_seconds`. Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2 login_ratelimit_max_burst: u32, false, def, 10; - /// Seconds between admin requests |> Number of seconds, on average, between admin requests before rate limiting kicks in + /// Seconds between admin requests |> Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in admin_ratelimit_seconds: u64, false, def, 300; /// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `admin_ratelimit_seconds` admin_ratelimit_max_burst: u32, false, def, 3;