diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bd65b2..707c93b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,28 @@ All notable changes to NORA will be documented in this file. --- +## [0.3.0] - 2026-01-30 + +### Added + +#### Configurable Rate Limiting +- Rate limits now configurable via `config.toml` and environment variables +- New config section `[rate_limit]` with 6 parameters: + - `auth_rps` / `auth_burst` - Authentication endpoints (brute-force protection) + - `upload_rps` / `upload_burst` - Upload endpoints (Docker push, etc.) + - `general_rps` / `general_burst` - General API endpoints +- Environment variables: `NORA_RATE_LIMIT_{AUTH|UPLOAD|GENERAL}_{RPS|BURST}` +- Rate limit configuration logged at startup + +### Changed +- Rate limiting functions now accept `&RateLimitConfig` parameter +- Improved error messages with `.expect()` instead of `.unwrap()` + +### Fixed +- Rate limiting was hardcoded in v0.2.0, now user-configurable + +--- + ## [0.2.0] - 2026-01-25 ### Added diff --git a/README.md b/README.md index ff7c50b..ffe2545 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,12 @@ nora migrate --from local --to s3 | `NORA_STORAGE_S3_URL` | - | S3 endpoint URL | | `NORA_STORAGE_BUCKET` | registry | S3 bucket name | | `NORA_AUTH_ENABLED` | false | Enable authentication | +| `NORA_RATE_LIMIT_AUTH_RPS` | 1 | Auth requests per second | +| `NORA_RATE_LIMIT_AUTH_BURST` | 5 | Auth burst size | +| `NORA_RATE_LIMIT_UPLOAD_RPS` | 200 | Upload requests per second | +| `NORA_RATE_LIMIT_UPLOAD_BURST` | 500 | Upload burst size | +| `NORA_RATE_LIMIT_GENERAL_RPS` | 100 | General requests per second | +| `NORA_RATE_LIMIT_GENERAL_BURST` | 200 | General burst size | ### config.toml @@ -120,6 +126,17 @@ path = "data/storage" [auth] enabled = false htpasswd_file = "users.htpasswd" + +[rate_limit] +# Strict limits for authentication (brute-force protection) +auth_rps = 1 +auth_burst = 5 +# High limits for CI/CD upload workloads +upload_rps = 200 +upload_burst = 500 +# Balanced limits for general API endpoints +general_rps = 100 +general_burst = 200 ``` ## Endpoints diff --git a/nora-registry/src/config.rs b/nora-registry/src/config.rs index 6ed9ac0..b812e42 100644 --- a/nora-registry/src/config.rs +++ b/nora-registry/src/config.rs @@ -14,6 +14,8 @@ pub struct Config { pub pypi: PypiConfig, #[serde(default)] pub auth: AuthConfig, + #[serde(default)] + pub rate_limit: RateLimitConfig, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -137,6 +139,64 @@ impl Default for AuthConfig { } } +/// Rate limiting configuration +/// +/// Controls request rate limits for different endpoint types. +/// +/// # Example +/// ```toml +/// [rate_limit] +/// auth_rps = 1 +/// auth_burst = 5 +/// upload_rps = 500 +/// upload_burst = 1000 +/// general_rps = 100 +/// general_burst = 200 +/// ``` +/// +/// # Environment Variables +/// - `NORA_RATE_LIMIT_AUTH_RPS` - Auth requests per second +/// - `NORA_RATE_LIMIT_AUTH_BURST` - Auth burst size +/// - `NORA_RATE_LIMIT_UPLOAD_RPS` - Upload requests per second +/// - `NORA_RATE_LIMIT_UPLOAD_BURST` - Upload burst size +/// - `NORA_RATE_LIMIT_GENERAL_RPS` - General requests per second +/// - `NORA_RATE_LIMIT_GENERAL_BURST` - General burst size +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RateLimitConfig { + #[serde(default = "default_auth_rps")] + pub auth_rps: u64, + #[serde(default = "default_auth_burst")] + pub auth_burst: u32, + #[serde(default = "default_upload_rps")] + pub upload_rps: u64, + #[serde(default = "default_upload_burst")] + pub upload_burst: u32, + #[serde(default = "default_general_rps")] + pub general_rps: u64, + #[serde(default = "default_general_burst")] + pub general_burst: u32, +} + +fn default_auth_rps() -> u64 { 1 } +fn default_auth_burst() -> u32 { 5 } +fn default_upload_rps() -> u64 { 200 } +fn default_upload_burst() -> u32 { 500 } +fn default_general_rps() -> u64 { 100 } +fn default_general_burst() -> u32 { 200 } + +impl Default for RateLimitConfig { + fn default() -> Self { + Self { + auth_rps: default_auth_rps(), + auth_burst: default_auth_burst(), + upload_rps: default_upload_rps(), + upload_burst: default_upload_burst(), + general_rps: default_general_rps(), + general_burst: default_general_burst(), + } + } +} + impl Config { /// Load configuration with priority: ENV > config.toml > defaults pub fn load() -> Self { @@ -223,6 +283,38 @@ impl Config { if let Ok(val) = env::var("NORA_AUTH_TOKEN_STORAGE") { self.auth.token_storage = val; } + + // Rate limit config + if let Ok(val) = env::var("NORA_RATE_LIMIT_AUTH_RPS") { + if let Ok(v) = val.parse::() { + self.rate_limit.auth_rps = v; + } + } + if let Ok(val) = env::var("NORA_RATE_LIMIT_AUTH_BURST") { + if let Ok(v) = val.parse::() { + self.rate_limit.auth_burst = v; + } + } + if let Ok(val) = env::var("NORA_RATE_LIMIT_UPLOAD_RPS") { + if let Ok(v) = val.parse::() { + self.rate_limit.upload_rps = v; + } + } + if let Ok(val) = env::var("NORA_RATE_LIMIT_UPLOAD_BURST") { + if let Ok(v) = val.parse::() { + self.rate_limit.upload_burst = v; + } + } + if let Ok(val) = env::var("NORA_RATE_LIMIT_GENERAL_RPS") { + if let Ok(v) = val.parse::() { + self.rate_limit.general_rps = v; + } + } + if let Ok(val) = env::var("NORA_RATE_LIMIT_GENERAL_BURST") { + if let Ok(v) = val.parse::() { + self.rate_limit.general_burst = v; + } + } } } @@ -243,6 +335,44 @@ impl Default for Config { npm: NpmConfig::default(), pypi: PypiConfig::default(), auth: AuthConfig::default(), + rate_limit: RateLimitConfig::default(), } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_rate_limit_default() { + let config = RateLimitConfig::default(); + assert_eq!(config.auth_rps, 1); + assert_eq!(config.auth_burst, 5); + assert_eq!(config.upload_rps, 200); + assert_eq!(config.upload_burst, 500); + assert_eq!(config.general_rps, 100); + assert_eq!(config.general_burst, 200); + } + + #[test] + fn test_rate_limit_from_toml() { + let toml = r#" + [server] + host = "127.0.0.1" + port = 4000 + + [storage] + mode = "local" + + [rate_limit] + auth_rps = 10 + upload_burst = 1000 + "#; + + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.rate_limit.auth_rps, 10); + assert_eq!(config.rate_limit.upload_burst, 1000); + assert_eq!(config.rate_limit.auth_burst, 5); // default + } +} diff --git a/nora-registry/src/main.rs b/nora-registry/src/main.rs index a7b5c34..3f4b7c0 100644 --- a/nora-registry/src/main.rs +++ b/nora-registry/src/main.rs @@ -179,6 +179,17 @@ fn init_logging(json_format: bool) { async fn run_server(config: Config, storage: Storage) { let start_time = Instant::now(); + // Log rate limiting configuration + info!( + auth_rps = config.rate_limit.auth_rps, + auth_burst = config.rate_limit.auth_burst, + upload_rps = config.rate_limit.upload_rps, + upload_burst = config.rate_limit.upload_burst, + general_rps = config.rate_limit.general_rps, + general_burst = config.rate_limit.general_burst, + "Rate limiting configured" + ); + // Load auth if enabled let auth = if config.auth.enabled { let path = Path::new(&config.auth.htpasswd_file); @@ -205,6 +216,11 @@ async fn run_server(config: Config, storage: Storage) { None }; + // Create rate limiters before moving config to state + let auth_limiter = rate_limit::auth_rate_limiter(&config.rate_limit); + let upload_limiter = rate_limit::upload_rate_limiter(&config.rate_limit); + let general_limiter = rate_limit::general_rate_limiter(&config.rate_limit); + let state = Arc::new(AppState { storage, config, @@ -216,7 +232,7 @@ async fn run_server(config: Config, storage: Storage) { }); // Token routes with strict rate limiting (brute-force protection) - let auth_routes = auth::token_routes().layer(rate_limit::auth_rate_limiter()); + let auth_routes = auth::token_routes().layer(auth_limiter); // Registry routes with upload rate limiting let registry_routes = Router::new() @@ -225,7 +241,7 @@ async fn run_server(config: Config, storage: Storage) { .merge(registry::npm_routes()) .merge(registry::cargo_routes()) .merge(registry::pypi_routes()) - .layer(rate_limit::upload_rate_limiter()); + .layer(upload_limiter); // Routes WITHOUT rate limiting (health, metrics, UI) let public_routes = Router::new() @@ -238,7 +254,7 @@ async fn run_server(config: Config, storage: Storage) { let rate_limited_routes = Router::new() .merge(auth_routes) .merge(registry_routes) - .layer(rate_limit::general_rate_limiter()); + .layer(general_limiter); let app = Router::new() .merge(public_routes) diff --git a/nora-registry/src/rate_limit.rs b/nora-registry/src/rate_limit.rs index 2c6bbea..9a83377 100644 --- a/nora-registry/src/rate_limit.rs +++ b/nora-registry/src/rate_limit.rs @@ -1,4 +1,3 @@ -#![allow(dead_code)] //! Rate limiting configuration and middleware //! //! Provides rate limiting to protect against: @@ -6,96 +5,69 @@ //! - DoS attacks on upload endpoints //! - General API abuse +use crate::config::RateLimitConfig; use tower_governor::governor::GovernorConfigBuilder; -/// Rate limit configuration -#[derive(Debug, Clone)] -pub struct RateLimitConfig { - /// Requests per second for auth endpoints (strict) - pub auth_rps: u32, - /// Burst size for auth endpoints - pub auth_burst: u32, - /// Requests per second for upload endpoints - pub upload_rps: u32, - /// Burst size for upload endpoints - pub upload_burst: u32, - /// Requests per second for general endpoints (lenient) - pub general_rps: u32, - /// Burst size for general endpoints - pub general_burst: u32, -} - -impl Default for RateLimitConfig { - fn default() -> Self { - Self { - auth_rps: 1, // 1 req/sec for auth (strict) - auth_burst: 5, // Allow burst of 5 - upload_rps: 200, // 200 req/sec for uploads (Docker needs high parallelism) - upload_burst: 500, // Allow burst of 500 - general_rps: 100, // 100 req/sec general - general_burst: 200, // Allow burst of 200 - } - } -} - /// Create rate limiter layer for auth endpoints (strict protection against brute-force) -/// -/// Default: 1 request per second, burst of 5 -pub fn auth_rate_limiter() -> tower_governor::GovernorLayer< +pub fn auth_rate_limiter( + config: &RateLimitConfig, +) -> tower_governor::GovernorLayer< tower_governor::key_extractor::PeerIpKeyExtractor, governor::middleware::StateInformationMiddleware, axum::body::Body, > { - let config = GovernorConfigBuilder::default() - .per_second(1) - .burst_size(5) + let gov_config = GovernorConfigBuilder::default() + .per_second(config.auth_rps) + .burst_size(config.auth_burst) .use_headers() .finish() - .unwrap(); + .expect("Failed to build auth rate limiter"); - tower_governor::GovernorLayer::new(config) + tower_governor::GovernorLayer::new(gov_config) } /// Create rate limiter layer for upload endpoints /// -/// Default: 200 requests per second, burst of 500 /// High limits to accommodate Docker client's aggressive parallel layer uploads -pub fn upload_rate_limiter() -> tower_governor::GovernorLayer< +pub fn upload_rate_limiter( + config: &RateLimitConfig, +) -> tower_governor::GovernorLayer< tower_governor::key_extractor::PeerIpKeyExtractor, governor::middleware::StateInformationMiddleware, axum::body::Body, > { - let config = GovernorConfigBuilder::default() - .per_second(200) - .burst_size(500) + let gov_config = GovernorConfigBuilder::default() + .per_second(config.upload_rps) + .burst_size(config.upload_burst) .use_headers() .finish() - .unwrap(); + .expect("Failed to build upload rate limiter"); - tower_governor::GovernorLayer::new(config) + tower_governor::GovernorLayer::new(gov_config) } /// Create rate limiter layer for general endpoints (lenient) -/// -/// Default: 100 requests per second, burst of 200 -pub fn general_rate_limiter() -> tower_governor::GovernorLayer< +pub fn general_rate_limiter( + config: &RateLimitConfig, +) -> tower_governor::GovernorLayer< tower_governor::key_extractor::PeerIpKeyExtractor, governor::middleware::StateInformationMiddleware, axum::body::Body, > { - let config = GovernorConfigBuilder::default() - .per_second(100) - .burst_size(200) + let gov_config = GovernorConfigBuilder::default() + .per_second(config.general_rps) + .burst_size(config.general_burst) .use_headers() .finish() - .unwrap(); + .expect("Failed to build general rate limiter"); - tower_governor::GovernorLayer::new(config) + tower_governor::GovernorLayer::new(gov_config) } #[cfg(test)] mod tests { use super::*; + use crate::config::RateLimitConfig; #[test] fn test_default_config() { @@ -108,16 +80,34 @@ mod tests { #[test] fn test_auth_rate_limiter_creation() { - let _limiter = auth_rate_limiter(); + let config = RateLimitConfig::default(); + let _limiter = auth_rate_limiter(&config); } #[test] fn test_upload_rate_limiter_creation() { - let _limiter = upload_rate_limiter(); + let config = RateLimitConfig::default(); + let _limiter = upload_rate_limiter(&config); } #[test] fn test_general_rate_limiter_creation() { - let _limiter = general_rate_limiter(); + let config = RateLimitConfig::default(); + let _limiter = general_rate_limiter(&config); + } + + #[test] + fn test_custom_config() { + let config = RateLimitConfig { + auth_rps: 10, + auth_burst: 20, + upload_rps: 500, + upload_burst: 1000, + general_rps: 200, + general_burst: 400, + }; + let _auth = auth_rate_limiter(&config); + let _upload = upload_rate_limiter(&config); + let _general = general_rate_limiter(&config); } }