fc-server: implent proper rate limiting with token bucket algorithm; fix rate_limit_rps
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I68237ff6216337eba1afa8e8606d545b6a6a6964
This commit is contained in:
parent
754f5afb6d
commit
d0ffa5d9e5
4 changed files with 110 additions and 60 deletions
|
|
@ -50,6 +50,9 @@ pub struct ServerConfig {
|
||||||
/// Allowed URL schemes for repository URLs. Insecure schemes emit a warning
|
/// Allowed URL schemes for repository URLs. Insecure schemes emit a warning
|
||||||
/// on startup
|
/// on startup
|
||||||
pub allowed_url_schemes: Vec<String>,
|
pub allowed_url_schemes: Vec<String>,
|
||||||
|
/// Force Secure flag on session cookies (enable when behind HTTPS reverse
|
||||||
|
/// proxy)
|
||||||
|
pub force_secure_cookies: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -513,6 +516,7 @@ impl Default for ServerConfig {
|
||||||
"git".into(),
|
"git".into(),
|
||||||
"ssh".into(),
|
"ssh".into(),
|
||||||
],
|
],
|
||||||
|
force_secure_cookies: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1277,17 +1277,10 @@ async fn login_action(
|
||||||
created_at: std::time::Instant::now(),
|
created_at: std::time::Instant::now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let secure_flag = if !state.config.server.cors_permissive
|
let security_flags =
|
||||||
&& state.config.server.host != "127.0.0.1"
|
crate::routes::cookie_security_flags(&state.config.server);
|
||||||
&& state.config.server.host != "localhost"
|
|
||||||
{
|
|
||||||
"; Secure"
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
};
|
|
||||||
let cookie = format!(
|
let cookie = format!(
|
||||||
"fc_user_session={session_id}; HttpOnly; SameSite=Strict; Path=/; \
|
"fc_user_session={session_id}; {security_flags}; Path=/; Max-Age=86400"
|
||||||
Max-Age=86400{secure_flag}"
|
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
[(axum::http::header::SET_COOKIE, cookie)],
|
[(axum::http::header::SET_COOKIE, cookie)],
|
||||||
|
|
@ -1341,17 +1334,10 @@ async fn login_action(
|
||||||
created_at: std::time::Instant::now(),
|
created_at: std::time::Instant::now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let secure_flag = if !state.config.server.cors_permissive
|
let security_flags =
|
||||||
&& state.config.server.host != "127.0.0.1"
|
crate::routes::cookie_security_flags(&state.config.server);
|
||||||
&& state.config.server.host != "localhost"
|
|
||||||
{
|
|
||||||
"; Secure"
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
};
|
|
||||||
let cookie = format!(
|
let cookie = format!(
|
||||||
"fc_session={session_id}; HttpOnly; SameSite=Strict; Path=/; \
|
"fc_session={session_id}; {security_flags}; Path=/; Max-Age=86400"
|
||||||
Max-Age=86400{secure_flag}"
|
|
||||||
);
|
);
|
||||||
(
|
(
|
||||||
[(axum::http::header::SET_COOKIE, cookie)],
|
[(axum::http::header::SET_COOKIE, cookie)],
|
||||||
|
|
|
||||||
|
|
@ -43,8 +43,35 @@ use crate::{
|
||||||
|
|
||||||
static STYLE_CSS: &str = include_str!("../../static/style.css");
|
static STYLE_CSS: &str = include_str!("../../static/style.css");
|
||||||
|
|
||||||
|
/// Helper to generate secure cookie flags based on server configuration.
|
||||||
|
/// Returns a string containing cookie security attributes: HttpOnly, SameSite,
|
||||||
|
/// and optionally Secure.
|
||||||
|
///
|
||||||
|
/// The Secure flag is set when:
|
||||||
|
/// 1. `force_secure_cookies` is enabled in config (for HTTPS reverse proxies),
|
||||||
|
/// OR 2. The server is not bound to localhost/127.0.0.1 AND not in permissive
|
||||||
|
/// mode
|
||||||
|
pub fn cookie_security_flags(
|
||||||
|
config: &fc_common::config::ServerConfig,
|
||||||
|
) -> String {
|
||||||
|
let is_localhost = config.host == "127.0.0.1"
|
||||||
|
|| config.host == "localhost"
|
||||||
|
|| config.host == "::1";
|
||||||
|
|
||||||
|
let secure_flag = if config.force_secure_cookies
|
||||||
|
|| (!is_localhost && !config.cors_permissive)
|
||||||
|
{
|
||||||
|
"; Secure"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
|
||||||
|
format!("HttpOnly; SameSite=Strict{secure_flag}")
|
||||||
|
}
|
||||||
|
|
||||||
struct RateLimitState {
|
struct RateLimitState {
|
||||||
requests: DashMap<IpAddr, Vec<Instant>>,
|
requests: DashMap<IpAddr, Vec<Instant>>,
|
||||||
|
rps: u64,
|
||||||
burst: u32,
|
burst: u32,
|
||||||
last_cleanup: std::sync::atomic::AtomicU64,
|
last_cleanup: std::sync::atomic::AtomicU64,
|
||||||
}
|
}
|
||||||
|
|
@ -89,10 +116,23 @@ async fn rate_limit_middleware(
|
||||||
let mut entry = rl.requests.entry(ip).or_default();
|
let mut entry = rl.requests.entry(ip).or_default();
|
||||||
entry.retain(|t| now.duration_since(*t) < window);
|
entry.retain(|t| now.duration_since(*t) < window);
|
||||||
|
|
||||||
if entry.len() >= rl.burst as usize {
|
// Token bucket algorithm: allow burst, then enforce rps limit
|
||||||
|
let request_count = entry.len();
|
||||||
|
if request_count >= rl.burst as usize {
|
||||||
return StatusCode::TOO_MANY_REQUESTS.into_response();
|
return StatusCode::TOO_MANY_REQUESTS.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If within burst but need to check rate, ensure we don't exceed rps
|
||||||
|
if request_count >= rl.rps as usize {
|
||||||
|
// Check if oldest request in window is still within the rps constraint
|
||||||
|
if let Some(oldest) = entry.first() {
|
||||||
|
let elapsed = now.duration_since(*oldest);
|
||||||
|
if elapsed < window {
|
||||||
|
return StatusCode::TOO_MANY_REQUESTS.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
entry.push(now);
|
entry.push(now);
|
||||||
drop(entry);
|
drop(entry);
|
||||||
}
|
}
|
||||||
|
|
@ -176,11 +216,12 @@ pub fn router(state: AppState, config: &ServerConfig) -> Router {
|
||||||
));
|
));
|
||||||
|
|
||||||
// Add rate limiting if configured
|
// Add rate limiting if configured
|
||||||
if let (Some(_rps), Some(burst)) =
|
if let (Some(rps), Some(burst)) =
|
||||||
(config.rate_limit_rps, config.rate_limit_burst)
|
(config.rate_limit_rps, config.rate_limit_burst)
|
||||||
{
|
{
|
||||||
let rl_state = Arc::new(RateLimitState {
|
let rl_state = Arc::new(RateLimitState {
|
||||||
requests: DashMap::new(),
|
requests: DashMap::new(),
|
||||||
|
rps,
|
||||||
burst,
|
burst,
|
||||||
last_cleanup: std::sync::atomic::AtomicU64::new(0),
|
last_cleanup: std::sync::atomic::AtomicU64::new(0),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -105,16 +105,26 @@ async fn github_login(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
.url();
|
.url();
|
||||||
|
|
||||||
// Store CSRF token in a cookie for verification
|
// Store CSRF token in a cookie for verification
|
||||||
// Add Secure flag when using HTTPS (detected via redirect_uri)
|
// Use SameSite=Lax for OAuth flow (must work across redirect)
|
||||||
let secure_flag = if config.redirect_uri.starts_with("https://") {
|
let security_flags = {
|
||||||
|
let is_localhost = config.redirect_uri.starts_with("http://localhost")
|
||||||
|
|| config.redirect_uri.starts_with("http://127.0.0.1");
|
||||||
|
|
||||||
|
let secure_flag = if state.config.server.force_secure_cookies
|
||||||
|
|| (!is_localhost && config.redirect_uri.starts_with("https://"))
|
||||||
|
{
|
||||||
"; Secure"
|
"; Secure"
|
||||||
} else {
|
} else {
|
||||||
""
|
""
|
||||||
};
|
};
|
||||||
|
|
||||||
|
format!("HttpOnly; SameSite=Lax{secure_flag}")
|
||||||
|
};
|
||||||
|
|
||||||
let cookie = format!(
|
let cookie = format!(
|
||||||
"fc_oauth_state={}; HttpOnly; SameSite=Lax; Path=/; Max-Age=600{}",
|
"fc_oauth_state={}; {}; Path=/; Max-Age=600",
|
||||||
csrf_token.secret(),
|
csrf_token.secret(),
|
||||||
secure_flag
|
security_flags
|
||||||
);
|
);
|
||||||
|
|
||||||
Response::builder()
|
Response::builder()
|
||||||
|
|
@ -263,20 +273,29 @@ async fn github_callback(
|
||||||
.map_err(ApiError)?;
|
.map_err(ApiError)?;
|
||||||
|
|
||||||
// Clear OAuth state cookie and set session cookie
|
// Clear OAuth state cookie and set session cookie
|
||||||
// Add Secure flag when using HTTPS (detected via redirect_uri)
|
// Use SameSite=Lax for OAuth callback (must work across redirect)
|
||||||
let secure_flag = if config.redirect_uri.starts_with("https://") {
|
let security_flags = {
|
||||||
|
let is_localhost = config.redirect_uri.starts_with("http://localhost")
|
||||||
|
|| config.redirect_uri.starts_with("http://127.0.0.1");
|
||||||
|
|
||||||
|
let secure_flag = if state.config.server.force_secure_cookies
|
||||||
|
|| (!is_localhost && config.redirect_uri.starts_with("https://"))
|
||||||
|
{
|
||||||
"; Secure"
|
"; Secure"
|
||||||
} else {
|
} else {
|
||||||
""
|
""
|
||||||
};
|
};
|
||||||
let clear_state = format!(
|
|
||||||
"fc_oauth_state=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0{secure_flag}"
|
format!("HttpOnly; SameSite=Lax{secure_flag}")
|
||||||
);
|
};
|
||||||
|
|
||||||
|
let clear_state =
|
||||||
|
format!("fc_oauth_state=; {}; Path=/; Max-Age=0", security_flags);
|
||||||
let session_cookie = format!(
|
let session_cookie = format!(
|
||||||
"fc_user_session={}; HttpOnly; SameSite=Lax; Path=/; Max-Age={}{}",
|
"fc_user_session={}; {}; Path=/; Max-Age={}",
|
||||||
session.0,
|
session.0,
|
||||||
7 * 24 * 60 * 60, // 7 days
|
security_flags,
|
||||||
secure_flag
|
7 * 24 * 60 * 60 // 7 days
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(
|
Ok(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue