aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorKunoiSayami <[email protected]>2026-02-09 21:50:12 +0800
committerKunoiSayami <[email protected]>2026-02-09 21:51:38 +0800
commitc708a63f7e5b9b22f8095ede0be56115eccbbac8 (patch)
tree6235e5079206f48551815cc3cf8349d74a4aaa4f /src
parent735612ccfba150978d174fcc7d95b2ea3013ab9e (diff)
feat: Add configurable Redis URL (claude)
* feat: Add login rate limiting (claude) * feat: Add SameSite cookie attribute (claude) Signed-off-by: KunoiSayami <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/authentication.rs57
-rw-r--r--src/datastructures.rs26
-rw-r--r--src/test.rs10
3 files changed, 79 insertions, 14 deletions
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<R: BufRead, W: Write> IOModule<R, W> {
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<R: BufRead, W: Write> IOModule<R, W> {
}
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<R: BufRead, W: Write> IOModule<R, W> {
.get_one::<String>("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<Config> 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(())