v0.2.22
RustBox CI / rustbox (push) Canceled after 0s

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-04 14:59:03 +01:00
1 parent 6e74b33f03
commit 3b9110c8c8
44 files changed
+3057 -128

No files matched your search

+352 -5
View File
@@ -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 [