From c708a63f7e5b9b22f8095ede0be56115eccbbac8 Mon Sep 17 00:00:00 2001 From: KunoiSayami Date: Mon, 9 Feb 2026 21:50:12 +0800 Subject: feat: Add configurable Redis URL (claude) * feat: Add login rate limiting (claude) * feat: Add SameSite cookie attribute (claude) Signed-off-by: KunoiSayami --- src/authentication.rs | 57 +++++++++++++++++++++++++++++++++++++++++++-------- src/datastructures.rs | 26 ++++++++++++++++++++--- src/test.rs | 10 ++++++--- 3 files changed, 79 insertions(+), 14 deletions(-) (limited to 'src') diff --git a/src/authentication.rs b/src/authentication.rs index 151d7e0..05707f0 100644 --- a/src/authentication.rs +++ b/src/authentication.rs @@ -1,4 +1,3 @@ - use crate::datastructures::{Config, Cookie, FormData, WrapConfigure}; use anyhow::Result; use clap::ArgMatches; @@ -41,6 +40,30 @@ impl IOModule { let cfg = WrapConfigure::from(cfg); log::trace!("Method is {}", cfg.get_authorizer().method()); + + // Establish Redis connection early for rate limiting + session storage + let redis_client = redis::Client::open(cfg.get_config().redis_url.as_str())?; + let mut conn = redis_client.get_multiplexed_async_connection().await?; + + // Rate limit check + let max_attempts = cfg.get_config().max_login_attempts; + let login_timeout = cfg.get_config().login_timeout; + + if max_attempts > 0 { + let rate_limit_key = format!("cgit_auth_failed_{}", data.get_user()); + let attempt_count: u64 = conn.get(&rate_limit_key).await.unwrap_or(0); + if attempt_count >= max_attempts { + log::warn!( + "User {} is locked out due to too many failed login attempts", + data.get_user() + ); + writeln!(&mut self.writer, "Status: 403 Forbidden")?; + writeln!(&mut self.writer, "Cache-Control: no-cache, no-store")?; + writeln!(&mut self.writer)?; + return Ok(()); + } + } + let ret = verify_login(&cfg, &data).await; if let Err(ref e) = ret { @@ -54,9 +77,13 @@ impl IOModule { } if ret.unwrap_or(false) { - let redis_conn = redis::Client::open("redis://127.0.0.1/")?; + // Clear failed attempt counter on successful login + if max_attempts > 0 { + let rate_limit_key = format!("cgit_auth_failed_{}", data.get_user()); + let _: () = conn.del(&rate_limit_key).await.unwrap_or(()); + } + let cookie = Cookie::generate(data.get_user()); - let mut conn = redis_conn.get_multiplexed_async_connection().await?; conn.set_ex::<_, _, String>( format!("cgit_auth_{}", cookie.get_key()), @@ -79,16 +106,31 @@ impl IOModule { .get_one::("http-referer") .map(|s| s.as_str()) .unwrap_or("/"); - let cookie_suffix = if is_secure { "; secure" } else { "" }; + let cookie_suffix = if is_secure { "; Secure" } else { "" }; writeln!(&mut self.writer, "Status: 302 Found")?; writeln!(&mut self.writer, "Cache-Control: no-cache, no-store")?; writeln!(&mut self.writer, "Location: {location}")?; writeln!( &mut self.writer, - "Set-Cookie: cgit_auth={cookie_value}; Domain={domain}; Max-Age={}; HttpOnly{cookie_suffix}", + "Set-Cookie: cgit_auth={cookie_value}; Domain={domain}; Max-Age={}; HttpOnly; SameSite=Lax{cookie_suffix}", cfg.get_config().cookie_ttl * 10, )?; } else { + // Increment rate limit counter on failed login + if max_attempts > 0 { + let rate_limit_key = format!("cgit_auth_failed_{}", data.get_user()); + let new_count: u64 = conn.incr(&rate_limit_key, 1u64).await.unwrap_or(1); + if new_count == 1 { + let _: () = conn + .expire(&rate_limit_key, login_timeout as i64) + .await + .unwrap_or(()); + } + log::info!( + "Failed login attempt {new_count}/{max_attempts} for user {}", + data.get_user() + ); + } writeln!(&mut self.writer, "Status: 403 Forbidden")?; writeln!(&mut self.writer, "Cache-Control: no-cache, no-store")?; } @@ -124,7 +166,7 @@ pub(crate) async fn cmd_authenticate_cookie(matches: &ArgMatches, cfg: Config) - return Ok(false); } - let redis_conn = redis::Client::open("redis://127.0.0.1/")?; + let redis_conn = redis::Client::open(cfg.redis_url.as_str())?; let mut conn = redis_conn.get_multiplexed_async_connection().await?; let redis_key = format!("cgit_repo_{repo}"); @@ -219,7 +261,6 @@ pub(crate) async fn verify_login(cfg: &WrapConfigure, data: &FormData) -> Result data.authorize(cfg.get_authorizer()).await } - #[derive(Serialize)] pub struct Meta<'a> { action: &'a str, @@ -463,7 +504,7 @@ pub(crate) async fn cmd_repo_user_control( return Err(anyhow::Error::msg("Invalid repository or username")); } - let redis_client = redis::Client::open("redis://127.0.0.1/")?; + let redis_client = redis::Client::open(cfg.redis_url.as_str())?; let mut redis_conn = redis_client.get_multiplexed_async_connection().await?; let mut conn = SqliteConnection::connect(cfg.get_database_location()).await?; diff --git a/src/datastructures.rs b/src/datastructures.rs index c312f8f..4ca1a00 100644 --- a/src/datastructures.rs +++ b/src/datastructures.rs @@ -23,7 +23,6 @@ use argon2::{ use base64::Engine; use log::error; use rand::RngExt; -use serde::{Deserialize, Serialize}; use sqlx::ConnectOptions; use std::borrow::Cow; use std::fmt::{Debug, Formatter}; @@ -36,6 +35,9 @@ use url::form_urlencoded; const DEFAULT_CONFIG_LOCATION: &str = "/etc/cgitrc"; const DEFAULT_COOKIE_TTL: u64 = 1200; const DEFAULT_DATABASE_LOCATION: &str = "/etc/cgit/auth.db"; +pub(crate) const DEFAULT_REDIS_URL: &str = "redis://127.0.0.1/"; +const DEFAULT_MAX_LOGIN_ATTEMPTS: u64 = 5; +const DEFAULT_LOGIN_TIMEOUT: u64 = 900; pub const CACHE_DIR: &str = "/var/cache/cgit"; pub type RandIntType = u32; pub const COOKIE_LENGTH: usize = 32; @@ -73,12 +75,14 @@ pub(crate) trait TestSuite { fn generate_test_config() -> Self; } - #[derive(Debug, Clone)] pub struct Config { pub cookie_ttl: u64, database: String, + pub redis_url: String, pub bypass_root: bool, + pub max_login_attempts: u64, + pub login_timeout: u64, pub(crate) test: bool, protect_config: ProtectSettings, } @@ -88,7 +92,10 @@ impl Default for Config { Self { cookie_ttl: DEFAULT_COOKIE_TTL, database: DEFAULT_DATABASE_LOCATION.to_string(), + redis_url: DEFAULT_REDIS_URL.to_string(), bypass_root: false, + max_login_attempts: DEFAULT_MAX_LOGIN_ATTEMPTS, + login_timeout: DEFAULT_LOGIN_TIMEOUT, test: false, protect_config: Default::default(), } @@ -105,7 +112,10 @@ impl Config { let mut cookie_ttl: u64 = DEFAULT_COOKIE_TTL; let mut database: &str = "/etc/cgit/auth.db"; + let mut redis_url: &str = DEFAULT_REDIS_URL; let mut bypass_root: bool = false; + let mut max_login_attempts: u64 = DEFAULT_MAX_LOGIN_ATTEMPTS; + let mut login_timeout: u64 = DEFAULT_LOGIN_TIMEOUT; let mut protect_enabled: bool = true; let mut protect_white_list_mode: bool = true; //let mut skip_user_access_check: bool = false; @@ -126,7 +136,12 @@ impl Config { match key_name { "cookie-ttl" => cookie_ttl = value.parse().unwrap_or(DEFAULT_COOKIE_TTL), "database" => database = value, + "redis-url" => redis_url = value, "bypass-root" => bypass_root = value.to_lowercase().eq("true"), + "max-login-attempts" => { + max_login_attempts = value.parse().unwrap_or(DEFAULT_MAX_LOGIN_ATTEMPTS) + } + "login-timeout" => login_timeout = value.parse().unwrap_or(DEFAULT_LOGIN_TIMEOUT), "protect" => match value.to_lowercase().as_str() { "full" => { protect_enabled = true; @@ -148,7 +163,10 @@ impl Config { Self { cookie_ttl, database: database.to_string(), + redis_url: redis_url.to_string(), bypass_root, + max_login_attempts, + login_timeout, test: false, protect_config: ProtectSettings::from_path( @@ -245,8 +263,11 @@ impl TestSuite for Config { fn generate_test_config() -> Self { Self { database: "test/tmp.db".to_string(), + redis_url: DEFAULT_REDIS_URL.to_string(), bypass_root: false, cookie_ttl: DEFAULT_COOKIE_TTL, + max_login_attempts: DEFAULT_MAX_LOGIN_ATTEMPTS, + login_timeout: DEFAULT_LOGIN_TIMEOUT, test: true, protect_config: ProtectSettings::generate_test_config(), } @@ -625,7 +646,6 @@ impl From for WrapConfigure { } } - #[derive(Debug, Clone)] struct SQLAuthorizer { database_location: String, diff --git a/src/test.rs b/src/test.rs index a1d961b..7628921 100644 --- a/src/test.rs +++ b/src/test.rs @@ -55,7 +55,7 @@ mod core { } async fn async_test_redis() -> anyhow::Result<()> { - let redis_conn = redis::Client::open("redis://127.0.0.1/")?; + let redis_conn = redis::Client::open(crate::datastructures::DEFAULT_REDIS_URL)?; let mut conn = redis_conn.get_multiplexed_async_connection().await?; let s = rand_str(crate::datastructures::COOKIE_LENGTH); @@ -344,10 +344,14 @@ mod core { } async fn clear_redis_setting() -> anyhow::Result<()> { - let client = redis::Client::open("redis://127.0.0.1")?; + let client = redis::Client::open(crate::datastructures::DEFAULT_REDIS_URL)?; let mut conn = client.get_multiplexed_async_connection().await?; - for key in &["cgit_repo_test", "cgit_repo_repo"] { + for key in &[ + "cgit_repo_test", + "cgit_repo_repo", + "cgit_auth_failed_hunter2", + ] { conn.del::<_, i32>(*key).await?; } Ok(()) -- cgit v1.3.1