From 79280ec527047380b563500c87997472ebfdf033 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 6 Aug 2026 02:07:47 +0000 Subject: [PATCH 1/2] fix(http): isolate health checks from admin API rate-limit bucket Public GET /api/v1/health shared the authenticated /api/* rate-limit bucket, so unauthenticated health probes could exhaust the admin API budget for the same client IP and deny stream management requests. Co-authored-by: Alexander Wagner --- src/http.rs | 57 +++++++++++++++++++++++++++++++++++++++++++++++ src/rate_limit.rs | 54 ++++++++++++++++++++++++++++++++++++++------ 2 files changed, 104 insertions(+), 7 deletions(-) diff --git a/src/http.rs b/src/http.rs index f157a54..0af432b 100644 --- a/src/http.rs +++ b/src/http.rs @@ -2730,6 +2730,46 @@ mod tests { ..Default::default() }); let app = router(state); + let (header, value) = bearer("token"); + + for _ in 0..3 { + let resp = app + .clone() + .oneshot( + Request::builder() + .uri("/api/v1/streams") + .header(header, &value) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + } + + let resp = app + .oneshot( + Request::builder() + .uri("/api/v1/streams") + .header(header, value) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS); + } + + #[tokio::test] + async fn health_rate_limit_does_not_exhaust_authenticated_api_budget() { + let state = test_state_with_config(ServerConfig { + api_token: "token".to_string(), + http_rate_limit_api: 3, + http_rate_limit_default: 3, + ..Default::default() + }); + let app = router(state); + let (header, value) = bearer("token"); for _ in 0..3 { let resp = app @@ -2746,6 +2786,7 @@ mod tests { } let resp = app + .clone() .oneshot( Request::builder() .uri("/api/v1/health") @@ -2755,6 +2796,22 @@ mod tests { .await .unwrap(); assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS); + + let resp = app + .oneshot( + Request::builder() + .uri("/api/v1/streams") + .header(header, value) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::OK, + "authenticated admin API must not share the public health rate-limit bucket" + ); } #[tokio::test] diff --git a/src/rate_limit.rs b/src/rate_limit.rs index 2238709..6b6924b 100644 --- a/src/rate_limit.rs +++ b/src/rate_limit.rs @@ -126,13 +126,19 @@ impl RateLimiter { true } - fn limit_for_path(&self, path: &str) -> usize { - if path.starts_with("/api/") { - self.config.api_max + /// Classify a request path into a rate-limit bucket and its per-window cap. + /// `/api/v1/health` is public and probed by orchestrators; it must not share + /// the authenticated `/api/*` bucket or unauthenticated health traffic can + /// exhaust the admin API budget for the same client IP. + fn classify_path(&self, path: &str) -> (usize, &'static str) { + if path == "/api/v1/health" { + (self.config.default_max, "health") + } else if path.starts_with("/api/") { + (self.config.api_max, "api") } else if path.starts_with("/stats") { - self.config.stats_max + (self.config.stats_max, "stats") } else { - self.config.default_max + (self.config.default_max, "default") } } } @@ -193,8 +199,8 @@ pub async fn middleware( ) -> Response { let path = request.uri().path(); let peer = client_ip(&request, limiter.trusted_proxies.as_slice()); - let key = format!("{}:{}", peer, path.split('/').nth(1).unwrap_or("")); - let max = limiter.limit_for_path(path); + let (max, bucket) = limiter.classify_path(path); + let key = format!("{peer}:{bucket}"); if !limiter.check(&key, max) { let method = request.method().as_str(); crate::log_warn!("HTTP: {method} {path} from {peer} → 429 rate limit exceeded"); @@ -295,6 +301,40 @@ mod tests { ); } + #[test] + fn health_uses_separate_bucket_from_authenticated_api() { + let limiter = RateLimiter::new( + HttpRateLimitConfig { + api_max: 3, + default_max: 60, + ..HttpRateLimitConfig::default() + }, + Vec::new(), + ); + + let (health_max, health_bucket) = limiter.classify_path("/api/v1/health"); + let (api_max, api_bucket) = limiter.classify_path("/api/v1/streams"); + assert_eq!(health_bucket, "health"); + assert_eq!(api_bucket, "api"); + assert_eq!(health_max, 60); + assert_eq!(api_max, 3); + + for i in 0..3 { + assert!( + limiter.check("127.0.0.1:api", api_max), + "api request {i} should succeed" + ); + } + assert!( + !limiter.check("127.0.0.1:api", api_max), + "api bucket should be exhausted" + ); + assert!( + limiter.check("127.0.0.1:health", health_max), + "health bucket must remain independent of the api bucket" + ); + } + #[test] fn stats_limit_uses_configured_bucket() { let limiter = RateLimiter::new( From 23023dca90a2b080fffbfed9592a5b04e56a68ef Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:08:07 +0000 Subject: [PATCH 2/2] Update Cargo.lock --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c7563d7..a1d84e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,9 +22,9 @@ dependencies = [ [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ]