diff --git a/.env.example b/.env.example index 6b261a2d..5d7ab012 100644 --- a/.env.example +++ b/.env.example @@ -347,6 +347,14 @@ GITLAWB_TASK_READ_RATE_LIMIT=1200 # brake caps a single-source flood. Uses GITLAWB_TRUSTED_PROXY below to resolve # the client IP. 0 disables. Default 120. GITLAWB_CREATE_RATE_LIMIT=120 +# Per-DID creation requests allowed inside the window below (repo create, +# issue create, PR create, register, fork). 0 is rejected at startup: it would +# silently disable the limiter. Default 10. +GITLAWB_RATE_LIMIT_MAX=10 +# Window the per-DID creation count above applies over, in seconds. 0 is +# rejected at startup: every timestamp would prune immediately, disabling the +# limiter. Default 3600. +GITLAWB_RATE_LIMIT_WINDOW_SECS=3600 # ── Peer-sync rate limiting (per client IP, uses GITLAWB_TRUSTED_PROXY below) ─ # /api/v1/peers/announce and /api/v1/sync/notify accept unsigned requests from diff --git a/crates/gitlawb-node/src/config.rs b/crates/gitlawb-node/src/config.rs index efa97701..2cba7684 100644 --- a/crates/gitlawb-node/src/config.rs +++ b/crates/gitlawb-node/src/config.rs @@ -244,6 +244,29 @@ pub struct Config { #[arg(long, env = "GITLAWB_SHUTDOWN_GRACE_SECS", default_value_t = 30)] pub shutdown_grace_secs: u64, + /// Maximum number of creation requests (repo create, issue create, PR + /// create, register, fork) allowed per DID within the rate-limit window. + /// 0 is rejected at parse time: it would silently disable the limiter + /// (RateLimiter treats max_requests == 0 as allow-all), not block every + /// creation request. + #[arg( + long, + env = "GITLAWB_RATE_LIMIT_MAX", + default_value_t = 10, + value_parser = clap::value_parser!(u32).range(1..) + )] + pub rate_limit_max_requests: u32, + + /// Rate-limit window size, in seconds, over which + /// `rate_limit_max_requests` applies. 0 is rejected at parse time: every + /// timestamp would prune immediately, silently disabling the limiter. + #[arg( + long, + env = "GITLAWB_RATE_LIMIT_WINDOW_SECS", + default_value_t = 3600, + value_parser = clap::value_parser!(u64).range(1..) + )] + pub rate_limit_window_secs: u64, /// Maximum wall-clock time a single served git operation (upload-pack / /// receive-pack through `run_git_service`) may run before it is aborted and /// its process group torn down, in seconds. Bounds a git that neither diff --git a/crates/gitlawb-node/src/main.rs b/crates/gitlawb-node/src/main.rs index 14911907..2c8698ba 100644 --- a/crates/gitlawb-node/src/main.rs +++ b/crates/gitlawb-node/src/main.rs @@ -356,9 +356,14 @@ async fn main() -> Result<()> { ); // Per-DID limiter for the creation endpoints. Keyed on the authenticated - // DID (attacker-varied), so bound its key set to cap memory. - let rate_limiter = - rate_limit::RateLimiter::new_bounded(10, std::time::Duration::from_secs(3600), 200_000); + // DID (attacker-varied), so bound its key set to cap memory. Max and window + // are operator-configurable via GITLAWB_RATE_LIMIT_MAX / + // GITLAWB_RATE_LIMIT_WINDOW_SECS; both reject 0 at parse time. + let rate_limiter = rate_limit::RateLimiter::new_bounded( + config.rate_limit_max_requests as usize, + std::time::Duration::from_secs(config.rate_limit_window_secs), + 200_000, + ); // Per-client-IP flood brake for the creation endpoints. The per-DID limiter // above is bypassed by a DID farm (one throwaway did:key per repo), which is diff --git a/crates/gitlawb-node/src/rate_limit.rs b/crates/gitlawb-node/src/rate_limit.rs index 6cbca4af..d5ed801a 100644 --- a/crates/gitlawb-node/src/rate_limit.rs +++ b/crates/gitlawb-node/src/rate_limit.rs @@ -70,6 +70,14 @@ impl RateLimiter { self.window.min(Duration::from_secs(1)) } + /// The configured window, exposed so a 429 can carry an honest + /// `retry-after`: a hardcoded value lies when the operator retunes the + /// window, and a client that honors the header would retry into another + /// 429 until the real window elapses. + pub fn window(&self) -> Duration { + self.window + } + pub(crate) async fn check(&self, key: &str) -> bool { // max_requests == 0 means the limiter is disabled, not "block all". if self.max_requests == 0 { @@ -293,12 +301,7 @@ pub async fn rate_limit_by_did(request: Request, next: Next) -> Response { if let (Some(limiter), Some(did)) = (limiter, did) { if !limiter.check(&did).await { - return ( - StatusCode::TOO_MANY_REQUESTS, - [("retry-after", "60")], - RATE_LIMIT_MESSAGE, - ) - .into_response(); + return too_many_requests(limiter.window().as_secs()); } } @@ -409,10 +412,10 @@ pub const RATE_LIMIT_MESSAGE: &str = "rate limit exceeded — try again later"; /// The shared 429 response for the per-IP flood brakes. Route-agnostic: this /// middleware now serves the push path AND the peer-sync routes, so the message /// stays generic (the offending path is recorded in the warn log below). -pub fn too_many_requests() -> Response { +pub fn too_many_requests(retry_after_secs: u64) -> Response { ( StatusCode::TOO_MANY_REQUESTS, - [("retry-after", "60")], + [("retry-after", retry_after_secs.to_string())], RATE_LIMIT_MESSAGE, ) .into_response() @@ -481,7 +484,7 @@ pub async fn rate_limit_by_ip(request: Request, next: Next) -> Response { if let Some(key) = client_key(request.headers(), peer, limiter.trust) { if !limiter.limiter.check(&key).await { tracing::warn!(key = %key, path = %request.uri().path(), "per-IP rate limit exceeded"); - return too_many_requests(); + return too_many_requests(limiter.limiter.window().as_secs()); } } }