1 parent
6e74b33f03
commit
3b9110c8c8
44 files changed
+3057
-128
No files matched your search
+352
-5
@@ -1,12 +1,14 @@
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::Path;
|
||||
use axum::extract::{ConnectInfo, Path};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::{Extension, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::auth::imap_auth::{self, AuthResult};
|
||||
use crate::auth::login_guard::{LoginBlocked, LoginGuard};
|
||||
use crate::auth::session::{NewAccountSession, SessionState, SessionStore};
|
||||
use crate::auth::user_data;
|
||||
use crate::config::AppConfig;
|
||||
@@ -150,9 +152,133 @@ fn extract_session_cookies(headers: &axum::http::HeaderMap) -> Vec<(String, Stri
|
||||
sessions
|
||||
}
|
||||
|
||||
/// Client IP used for login throttling; honours `trusted_proxies` the same way
|
||||
/// as the per-IP token bucket and the security telemetry.
|
||||
fn login_client_ip(
|
||||
config: &AppConfig,
|
||||
extensions: &axum::http::Extensions,
|
||||
headers: &axum::http::HeaderMap,
|
||||
) -> IpAddr {
|
||||
crate::routes::system_diagnostics::effective_client_ip(config, headers, peer_ip(extensions))
|
||||
}
|
||||
|
||||
fn peer_ip(extensions: &axum::http::Extensions) -> IpAddr {
|
||||
extensions
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
.map(|info| info.0.ip())
|
||||
.unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
|
||||
}
|
||||
|
||||
/// Whether issued/cleared session cookies carry the `Secure` attribute.
|
||||
///
|
||||
/// `cookie_secure = "auto"` (default) marks cookies `Secure` only when the
|
||||
/// browser actually reached RustBox over HTTPS, so plain-HTTP installations
|
||||
/// keep working: a `Secure` cookie sent over HTTP is discarded by browsers and
|
||||
/// the user could never stay logged in.
|
||||
pub(crate) fn cookie_secure(
|
||||
config: &AppConfig,
|
||||
extensions: &axum::http::Extensions,
|
||||
headers: &axum::http::HeaderMap,
|
||||
) -> bool {
|
||||
match config.cookie_secure.trim().to_ascii_lowercase().as_str() {
|
||||
"always" => true,
|
||||
"never" => false,
|
||||
_ => request_is_https(config, extensions, headers),
|
||||
}
|
||||
}
|
||||
|
||||
/// RustBox itself serves plain HTTP, so HTTPS can only be terminated in front
|
||||
/// of it. A scheme reported by a trusted reverse proxy (`X-Forwarded-Proto`,
|
||||
/// `Forwarded: proto=`, `X-Forwarded-Ssl`) wins; otherwise the scheme of the
|
||||
/// browser-supplied `Origin`/`Referer` is used. A forged header can only change
|
||||
/// the cookies issued to the client that forged it.
|
||||
fn request_is_https(
|
||||
config: &AppConfig,
|
||||
extensions: &axum::http::Extensions,
|
||||
headers: &axum::http::HeaderMap,
|
||||
) -> bool {
|
||||
let peer = peer_ip(extensions);
|
||||
let peer_is_trusted_proxy = config
|
||||
.parsed_trusted_proxies()
|
||||
.iter()
|
||||
.any(|proxy| proxy.contains(&peer));
|
||||
if peer_is_trusted_proxy && let Some(proto) = forwarded_proto(headers) {
|
||||
return proto == "https";
|
||||
}
|
||||
header_url_is_https(headers, "origin") || header_url_is_https(headers, "referer")
|
||||
}
|
||||
|
||||
fn forwarded_proto(headers: &axum::http::HeaderMap) -> Option<String> {
|
||||
let header = |name: &str| headers.get(name).and_then(|value| value.to_str().ok());
|
||||
|
||||
if let Some(proto) = header("x-forwarded-proto")
|
||||
.and_then(|value| value.split(',').next())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return Some(proto.to_ascii_lowercase());
|
||||
}
|
||||
if let Some(forwarded) = header("forwarded") {
|
||||
for element in forwarded.split(',') {
|
||||
for pair in element.split(';') {
|
||||
if let Some((key, value)) = pair.split_once('=')
|
||||
&& key.trim().eq_ignore_ascii_case("proto")
|
||||
{
|
||||
return Some(value.trim().trim_matches('"').to_ascii_lowercase());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if header("x-forwarded-ssl").is_some_and(|value| value.trim().eq_ignore_ascii_case("on")) {
|
||||
return Some("https".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn header_url_is_https(headers: &axum::http::HeaderMap, name: &str) -> bool {
|
||||
headers
|
||||
.get(name)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.trim().get(..8))
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("https://"))
|
||||
}
|
||||
|
||||
fn too_many_login_attempts(blocked: LoginBlocked) -> Response {
|
||||
let retry_after = blocked.retry_after_secs();
|
||||
(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
[
|
||||
("content-type", "application/json".to_string()),
|
||||
("retry-after", retry_after.to_string()),
|
||||
],
|
||||
serde_json::json!({
|
||||
"error": {
|
||||
"code": "TOO_MANY_LOGIN_ATTEMPTS",
|
||||
"message": format!(
|
||||
"Too many failed login attempts. Try again in {}.",
|
||||
human_wait(retry_after)
|
||||
),
|
||||
"status": 429,
|
||||
"retryAfterSecs": retry_after
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn human_wait(secs: u64) -> String {
|
||||
if secs < 60 {
|
||||
format!("{secs} s")
|
||||
} else {
|
||||
format!("{} min", secs.div_ceil(60))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
Extension(store): Extension<Arc<SessionStore>>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
extensions: axum::http::Extensions,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<LoginRequest>,
|
||||
) -> Response {
|
||||
@@ -192,6 +318,20 @@ pub async fn login(
|
||||
.into_response();
|
||||
};
|
||||
|
||||
let login_guard = extensions.get::<Arc<LoginGuard>>().cloned();
|
||||
let client_ip = login_client_ip(&config, &extensions, &headers);
|
||||
if let Some(guard) = login_guard.as_deref()
|
||||
&& let Err(blocked) = guard.check(&body.email, client_ip)
|
||||
{
|
||||
tracing::warn!(
|
||||
source_ip = %client_ip,
|
||||
scope = blocked.scope.as_str(),
|
||||
retry_after_secs = blocked.retry_after_secs(),
|
||||
"login refused: brute-force lockout active"
|
||||
);
|
||||
return too_many_login_attempts(blocked);
|
||||
}
|
||||
|
||||
let smtp_host = config
|
||||
.smtp_host
|
||||
.clone()
|
||||
@@ -214,6 +354,23 @@ pub async fn login(
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Some(guard) = login_guard.as_deref() {
|
||||
match &result {
|
||||
AuthResult::Success => guard.record_success(&body.email),
|
||||
AuthResult::InvalidCredentials => {
|
||||
if let Some(locked) = guard.record_failure(&body.email, client_ip) {
|
||||
tracing::warn!(
|
||||
source_ip = %client_ip,
|
||||
scope = locked.scope.as_str(),
|
||||
lockout_secs = locked.retry_after_secs(),
|
||||
"login brute-force lockout triggered"
|
||||
);
|
||||
}
|
||||
}
|
||||
AuthResult::ServerUnreachable(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
match result {
|
||||
AuthResult::Success => {
|
||||
let provided_browser_id = body
|
||||
@@ -366,7 +523,7 @@ pub async fn login(
|
||||
store.set_user_agent_shared(&account_id, user_agent).await;
|
||||
|
||||
let max_age = session_hours * 3600;
|
||||
let secure = config.environment != "development";
|
||||
let secure = cookie_secure(&config, &extensions, &headers);
|
||||
let same_site = same_site_policy(&config);
|
||||
let path = cookie_path(&config);
|
||||
let browser_cookie_header =
|
||||
@@ -439,9 +596,10 @@ pub async fn get_session(Extension(session): Extension<SessionState>) -> Respons
|
||||
pub async fn list_accounts(
|
||||
Extension(store): Extension<Arc<SessionStore>>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
extensions: axum::http::Extensions,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Response {
|
||||
let secure = config.environment != "development";
|
||||
let secure = cookie_secure(&config, &extensions, &headers);
|
||||
let same_site = same_site_policy(&config);
|
||||
|
||||
let browser_id = extract_browser_id(&headers);
|
||||
@@ -570,6 +728,7 @@ pub async fn remove_account(
|
||||
Extension(store): Extension<Arc<SessionStore>>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
Path(account_id): Path<String>,
|
||||
extensions: axum::http::Extensions,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Response {
|
||||
let browser_id = extract_browser_id(&headers);
|
||||
@@ -611,7 +770,7 @@ pub async fn remove_account(
|
||||
|
||||
store.remove_account_shared(&account_id).await;
|
||||
|
||||
let secure = config.environment != "development";
|
||||
let secure = cookie_secure(&config, &extensions, &headers);
|
||||
let same_site = same_site_policy(&config);
|
||||
let cookie = clearing_account_cookie(&account_id, secure, same_site, cookie_path(&config));
|
||||
|
||||
@@ -764,13 +923,14 @@ pub async fn revoke_other_sessions(
|
||||
pub async fn logout(
|
||||
Extension(store): Extension<Arc<SessionStore>>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
extensions: axum::http::Extensions,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Response {
|
||||
let browser_id = extract_browser_id(&headers);
|
||||
|
||||
let mut cookies_to_clear: Vec<String> = Vec::new();
|
||||
|
||||
let secure = config.environment != "development";
|
||||
let secure = cookie_secure(&config, &extensions, &headers);
|
||||
let same_site = same_site_policy(&config);
|
||||
|
||||
if let Some(ref browser_id) = browser_id {
|
||||
@@ -947,6 +1107,7 @@ mod tests {
|
||||
let first = login(
|
||||
Extension(store.clone()),
|
||||
Extension(config.clone()),
|
||||
axum::http::Extensions::new(),
|
||||
axum::http::HeaderMap::new(),
|
||||
Json(request("first@example.com")),
|
||||
)
|
||||
@@ -968,6 +1129,7 @@ mod tests {
|
||||
let second = login(
|
||||
Extension(store.clone()),
|
||||
Extension(config.clone()),
|
||||
axum::http::Extensions::new(),
|
||||
headers.clone(),
|
||||
Json(request("second@example.com")),
|
||||
)
|
||||
@@ -995,6 +1157,7 @@ mod tests {
|
||||
let duplicate = login(
|
||||
Extension(store.clone()),
|
||||
Extension(config),
|
||||
axum::http::Extensions::new(),
|
||||
headers,
|
||||
Json(request("second@example.com")),
|
||||
)
|
||||
@@ -1004,6 +1167,190 @@ mod tests {
|
||||
imap.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repeated_failed_logins_lock_the_account_before_contacting_imap() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let connections = Arc::new(AtomicUsize::new(0));
|
||||
let seen = connections.clone();
|
||||
let imap = tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((stream, _)) = listener.accept().await else {
|
||||
break;
|
||||
};
|
||||
seen.fetch_add(1, Ordering::SeqCst);
|
||||
let mut stream = BufReader::new(stream);
|
||||
stream
|
||||
.get_mut()
|
||||
.write_all(b"* OK test IMAP ready\r\n")
|
||||
.await
|
||||
.unwrap();
|
||||
let mut command = String::new();
|
||||
stream.read_line(&mut command).await.unwrap();
|
||||
let tag = command
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or("A1")
|
||||
.to_string();
|
||||
let response = format!("{tag} NO [AUTHENTICATIONFAILED] Invalid credentials\r\n");
|
||||
let _ = stream.get_mut().write_all(response.as_bytes()).await;
|
||||
}
|
||||
});
|
||||
|
||||
let data_dir = tempfile::tempdir().unwrap();
|
||||
let mut config = test_config("development", None);
|
||||
config.data_dir = data_dir.path().to_str().unwrap().to_string();
|
||||
config.imap_host = Some("127.0.0.1".to_string());
|
||||
config.imap_port = port;
|
||||
config.tls_enabled = false;
|
||||
config.login_rate_limit.account_max_failures = 2;
|
||||
config.login_rate_limit.lockout_secs = 120;
|
||||
let guard = Arc::new(LoginGuard::from_config(&config.login_rate_limit));
|
||||
let config = Arc::new(config);
|
||||
let store = Arc::new(SessionStore::new(std::time::Duration::from_secs(3600)));
|
||||
|
||||
let attempt = |email: &'static str| {
|
||||
let mut extensions = axum::http::Extensions::new();
|
||||
extensions.insert(guard.clone());
|
||||
extensions.insert(ConnectInfo::<SocketAddr>(
|
||||
"198.51.100.7:50000".parse().unwrap(),
|
||||
));
|
||||
login(
|
||||
Extension(store.clone()),
|
||||
Extension(config.clone()),
|
||||
extensions,
|
||||
axum::http::HeaderMap::new(),
|
||||
Json(LoginRequest {
|
||||
email: email.to_string(),
|
||||
password: "wrong".to_string(),
|
||||
remember: false,
|
||||
browser_id: None,
|
||||
}),
|
||||
)
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
attempt("victim@example.com").await.status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
assert_eq!(
|
||||
attempt("Victim@Example.com").await.status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
|
||||
let locked = attempt("victim@example.com").await;
|
||||
assert_eq!(locked.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||
let retry_after: u64 = locked
|
||||
.headers()
|
||||
.get("retry-after")
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.parse()
|
||||
.unwrap();
|
||||
assert!((1..=120).contains(&retry_after));
|
||||
assert_eq!(connections.load(Ordering::SeqCst), 2);
|
||||
|
||||
// Another mailbox from the same IP is still allowed to try.
|
||||
assert_eq!(
|
||||
attempt("other@example.com").await.status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
assert_eq!(connections.load(Ordering::SeqCst), 3);
|
||||
imap.abort();
|
||||
}
|
||||
|
||||
fn request_parts(
|
||||
peer: &str,
|
||||
headers: &[(&'static str, &'static str)],
|
||||
) -> (axum::http::Extensions, axum::http::HeaderMap) {
|
||||
let mut extensions = axum::http::Extensions::new();
|
||||
extensions.insert(ConnectInfo::<SocketAddr>(peer.parse().unwrap()));
|
||||
let mut map = axum::http::HeaderMap::new();
|
||||
for &(name, value) in headers {
|
||||
map.append(name, axum::http::HeaderValue::from_static(value));
|
||||
}
|
||||
(extensions, map)
|
||||
}
|
||||
|
||||
fn secure_for(
|
||||
config: &AppConfig,
|
||||
peer: &str,
|
||||
headers: &[(&'static str, &'static str)],
|
||||
) -> bool {
|
||||
let (extensions, headers) = request_parts(peer, headers);
|
||||
cookie_secure(config, &extensions, &headers)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_cookie_secure_follows_the_real_request_scheme() {
|
||||
let mut config = test_config("production", None);
|
||||
config.trusted_proxies = Some("127.0.0.1,10.0.0.0/8".to_string());
|
||||
let client = "192.0.2.10:5000";
|
||||
let proxy = "10.0.0.2:5000";
|
||||
|
||||
// Plain HTTP install, even in production: no Secure flag, login keeps working.
|
||||
assert!(!secure_for(&config, client, &[]));
|
||||
assert!(!secure_for(
|
||||
&config,
|
||||
client,
|
||||
&[("origin", "http://mail.example.com")]
|
||||
));
|
||||
// Browser on HTTPS (TLS terminated in front of RustBox).
|
||||
assert!(secure_for(
|
||||
&config,
|
||||
client,
|
||||
&[("origin", "https://mail.example.com")]
|
||||
));
|
||||
assert!(secure_for(
|
||||
&config,
|
||||
"127.0.0.1:5000",
|
||||
&[("referer", "HTTPS://mail.example.com/inbox")]
|
||||
));
|
||||
// Trusted reverse proxy reports the scheme explicitly and wins.
|
||||
assert!(secure_for(
|
||||
&config,
|
||||
proxy,
|
||||
&[("x-forwarded-proto", "https")]
|
||||
));
|
||||
assert!(secure_for(
|
||||
&config,
|
||||
proxy,
|
||||
&[("forwarded", "for=192.0.2.1;proto=https")]
|
||||
));
|
||||
assert!(secure_for(&config, proxy, &[("x-forwarded-ssl", "on")]));
|
||||
assert!(!secure_for(
|
||||
&config,
|
||||
proxy,
|
||||
&[
|
||||
("x-forwarded-proto", "http"),
|
||||
("origin", "https://mail.example.com")
|
||||
]
|
||||
));
|
||||
// Untrusted peers cannot assert the scheme through proxy headers.
|
||||
assert!(!secure_for(
|
||||
&config,
|
||||
client,
|
||||
&[("x-forwarded-proto", "https")]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cookie_secure_can_be_forced_from_config() {
|
||||
let (extensions, headers) = request_parts("192.0.2.10:5000", &[]);
|
||||
let mut config = test_config("development", None);
|
||||
config.cookie_secure = "always".to_string();
|
||||
assert!(cookie_secure(&config, &extensions, &headers));
|
||||
|
||||
let (extensions, headers) =
|
||||
request_parts("192.0.2.10:5000", &[("origin", "https://mail.example.com")]);
|
||||
config.cookie_secure = "never".to_string();
|
||||
assert!(!cookie_secure(&config, &extensions, &headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issued_and_clearing_cookies_use_the_normalized_base_path() {
|
||||
for (base_path, expected_path) in [
|
||||
|
||||
Reference in new issue
Block a user