-
Notifications
You must be signed in to change notification settings - Fork 2
Security fix: isolate health checks from admin API rate-limit bucket #126
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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") | ||
|
Comment on lines
+136
to
+137
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an attacker shares the resolved client IP with an administrator, they can still exhaust this bucket by repeatedly requesting a protected route such as Useful? React with 👍 / 👎. |
||
| } else if path.starts_with("/stats") { | ||
| self.config.stats_max | ||
| (self.config.stats_max, "stats") | ||
|
Comment on lines
138
to
+139
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the same client polls both Useful? React with 👍 / 👎. |
||
| } 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( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When operators tune the published rate-limit settings, this silently makes
/api/v1/healthuseHTTP_RATE_LIMIT_DEFAULT, although.env.exampledocumentsHTTP_RATE_LIMIT_APIas covering/api/*and the default setting as covering all other routes. For example, a deployment with API=120 and DEFAULT=1 now returns 429 on its second health probe even though its documented/api/*allowance is 120. Either expose/document a health-specific setting or update the configuration contract so deployments do not unexpectedly break health monitoring.Useful? React with 👍 / 👎.