Skip to content

Commit 5835fb9

Browse files
committed
Add ip-based rate limiting on all POST auth APIs.
This is an effort to improve abuse protection, e.g. sign-up bombing. Logins where already rate-limited on email address.
1 parent 8c3879a commit 5835fb9

File tree

16 files changed

+273
-45
lines changed

16 files changed

+273
-45
lines changed

Cargo.lock

Lines changed: 116 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/core/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ tokio = { workspace = true }
8585
tokio-rustls = { version = "0.26.1", default-features = false }
8686
tower = "0.5.0"
8787
tower-cookies = "0.11.0"
88+
tower_governor = { version = "0.8.0", default-features = false, features = ["axum"] }
8889
tower-http = { version = "^0.6.0", default-features = false, features = ["cors", "trace", "fs", "limit"] }
8990
tower-service = { version = "0.3.3", default-features = false }
9091
tracing = { workspace = true }

crates/core/src/auth/api/change_email.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ pub async fn change_email_request_handler(
7575
const RATE_LIMIT_SEC: i64 = 10;
7676
let age: chrono::Duration = chrono::Utc::now() - timestamp;
7777
if age < chrono::Duration::seconds(RATE_LIMIT_SEC) {
78-
return Err(AuthError::BadRequest("verification sent already"));
78+
return Err(AuthError::TooManyRequests);
7979
}
8080
}
8181

crates/core/src/auth/api/login.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ pub async fn check_credentials(
282282
return AuthError::Unauthorized;
283283
})?;
284284

285-
// Validate password.
285+
// Validates password and rate limits attempts.
286286
check_user_password(&db_user, password, state.demo_mode())?;
287287

288288
return Ok(());
@@ -301,7 +301,7 @@ pub(crate) async fn login_with_password(
301301
return AuthError::Unauthorized;
302302
})?;
303303

304-
// Validate password.
304+
// Validates password and rate limits attempts.
305305
check_user_password(&db_user, password, state.demo_mode())?;
306306

307307
let user_id = db_user.uuid();

crates/core/src/auth/api/reset_password.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ pub async fn reset_password_request_handler(
5858

5959
let age: chrono::Duration = chrono::Utc::now() - timestamp;
6060
if age < chrono::Duration::seconds(RATE_LIMIT_SEC) {
61-
return Err(AuthError::BadRequest("Password reset sent already"));
61+
return Err(AuthError::TooManyRequests);
6262
}
6363
}
6464

crates/core/src/auth/api/verify_email.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ pub async fn request_email_verification_handler(
4747

4848
let age: chrono::Duration = chrono::Utc::now() - timestamp;
4949
if age < chrono::Duration::seconds(RATE_LIMIT_SEC) {
50-
return Err(AuthError::BadRequest("verification sent already"));
50+
return Err(AuthError::BadRequest("verification already sent"));
5151
}
5252
}
5353

crates/core/src/auth/error.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ pub enum AuthError {
2020
OAuthProviderNotFound,
2121
#[error("Bad request: {0}")]
2222
BadRequest(&'static str),
23+
#[error("Too many requests")]
24+
TooManyRequests,
2325
#[error("Failed dependency: {0}")]
2426
FailedDependency(Box<dyn std::error::Error + Send + Sync>),
2527
#[error("Internal: {0}")]
@@ -71,6 +73,7 @@ impl IntoResponse for AuthError {
7173
Self::NotFound => (StatusCode::NOT_FOUND, None),
7274
Self::OAuthProviderNotFound => (StatusCode::METHOD_NOT_ALLOWED, None),
7375
Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, Some(msg.to_string())),
76+
Self::TooManyRequests => (StatusCode::TOO_MANY_REQUESTS, None),
7477
Self::FailedDependency(err) if cfg!(debug_assertions) => {
7578
(StatusCode::FAILED_DEPENDENCY, Some(err.to_string()))
7679
}

crates/core/src/auth/password.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ pub fn check_user_password(
105105
let attempts = ATTEMPTS.get(&db_user.email);
106106

107107
if !is_demo && attempts.as_ref().map(|a| a.tries).unwrap_or(0) >= LOGIN_RATE_LIMIT {
108-
return Err(AuthError::Unauthorized);
108+
return Err(AuthError::TooManyRequests);
109109
}
110110

111111
let parsed_hash = PasswordHash::new(&db_user.password_hash)

crates/core/src/extract/ip.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
use axum::extract::ConnectInfo;
2+
use axum::http::Request;
3+
use std::net::IpAddr;
4+
use tower_governor::GovernorError;
5+
use tower_governor::key_extractor::KeyExtractor;
6+
7+
pub fn extract_ip<T>(req: &Request<T>) -> Option<std::net::IpAddr> {
8+
let headers = req.headers();
9+
10+
// NOTE: This code is mimicking axum_client_ip's pre v1 `InsecureClientIp::from`:
11+
return client_ip::rightmost_x_forwarded_for(headers)
12+
.or_else(|_| client_ip::x_real_ip(headers))
13+
.or_else(|_| client_ip::fly_client_ip(headers))
14+
.or_else(|_| client_ip::true_client_ip(headers))
15+
.or_else(|_| client_ip::cf_connecting_ip(headers))
16+
.or_else(|_| client_ip::cloudfront_viewer_address(headers))
17+
.ok()
18+
.or_else(|| {
19+
req
20+
.extensions()
21+
.get::<ConnectInfo<std::net::SocketAddr>>()
22+
.map(|ConnectInfo(addr)| addr.ip())
23+
});
24+
}
25+
26+
#[derive(Debug, Clone)]
27+
pub struct RealIpKeyExtractor;
28+
29+
impl KeyExtractor for RealIpKeyExtractor {
30+
type Key = IpAddr;
31+
32+
fn extract<T>(&self, req: &Request<T>) -> Result<Self::Key, GovernorError> {
33+
return extract_ip(req).ok_or_else(|| GovernorError::UnableToExtractKey);
34+
}
35+
36+
// fn name(&self) -> &'static str {
37+
// "smart IP"
38+
// }
39+
40+
// fn key_name(&self, key: &Self::Key) -> Option<String> {
41+
// Some(key.to_string())
42+
// }
43+
}
44+
45+
#[allow(unused)]
46+
pub fn ipv6_privacy_mask(ip: IpAddr) -> IpAddr {
47+
return match ip {
48+
IpAddr::V4(ip) => IpAddr::V4(ip),
49+
IpAddr::V6(ip) => IpAddr::V6(From::from(
50+
ip.to_bits() & 0xFFFF_FFFF_FFFF_FFFF_0000_0000_0000_0000,
51+
)),
52+
};
53+
}

crates/core/src/extract/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
mod either;
2+
pub mod ip;
23
mod multipart;
34
pub mod protobuf;
45

0 commit comments

Comments
 (0)