-
Notifications
You must be signed in to change notification settings - Fork 1k
cubeapi: key the rate limiter on the validated identity, not an unvalidated header #1380
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
base: master
Are you sure you want to change the base?
Changes from all commits
7f6c39c
3ef5ef4
5511d29
2fdf4da
f05fdd4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,9 @@ use axum::{ | |
| response::Response, | ||
| }; | ||
|
|
||
| #[derive(Debug, Clone)] | ||
| pub struct RateLimitIdentity(pub String); | ||
|
|
||
| /// Auth credential extracted from the request headers. | ||
| #[derive(Debug)] | ||
| enum AuthCredential { | ||
|
|
@@ -47,6 +50,24 @@ fn extract_credential(request: &Request) -> Option<AuthCredential> { | |
| None | ||
| } | ||
|
|
||
| fn identity_of(credential: &AuthCredential) -> String { | ||
| match credential { | ||
| AuthCredential::Bearer(t) => format!("bearer:{}", t), | ||
| AuthCredential::ApiKey(k) => format!("apikey:{}", k), | ||
| } | ||
| } | ||
|
|
||
| fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { | ||
| if a.len() != b.len() { | ||
| return false; | ||
| } | ||
| let mut diff = 0u8; | ||
| for (x, y) in a.iter().zip(b.iter()) { | ||
| diff |= x ^ y; | ||
| } | ||
| diff == 0 | ||
| } | ||
|
|
||
| /// Unified auth middleware. | ||
| /// | ||
| /// Behavior (priority order): | ||
|
|
@@ -78,7 +99,7 @@ fn extract_credential(request: &Request) -> Option<AuthCredential> { | |
| /// callback to enforce fine-grained (path + method) authorization. | ||
| pub async fn unified_auth( | ||
| State(state): State<AppState>, | ||
| request: Request, | ||
| mut request: Request, | ||
| next: Next, | ||
| ) -> Result<Response, AppError> { | ||
| // Mode 1: callback auth — if a callback URL is configured, forward the | ||
|
|
@@ -103,7 +124,7 @@ pub async fn unified_auth( | |
| AuthCredential::Bearer(t) => t.as_str(), | ||
| AuthCredential::ApiKey(k) => k.as_str(), | ||
| }; | ||
| if provided != expected_key { | ||
| if !constant_time_eq(provided.as_bytes(), expected_key.as_bytes()) { | ||
| tracing::warn!( | ||
| path = %request.uri().path(), | ||
| method = %request.method(), | ||
|
|
@@ -113,6 +134,10 @@ pub async fn unified_auth( | |
| "Invalid API key or token".to_string(), | ||
| )); | ||
| } | ||
| request.extensions_mut().insert(RateLimitIdentity(format!( | ||
| "configured-key:{}", | ||
| expected_key | ||
| ))); | ||
| } | ||
| } | ||
| // Mode 3: no auth (both unset) or simple-key match — pass through. | ||
|
|
@@ -160,6 +185,9 @@ pub async fn unified_auth( | |
| }; | ||
|
|
||
| if callback_resp.status().as_u16() == 200 { | ||
| request | ||
|
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. The callback-mode identity stores the raw credential as the bucket key. For Bearer tokens this is often a multi-KB JWT, so every distinct token becomes a large DashMap entry that lingers until the GC sweep — and raw credentials stay resident in memory past the request. Since the limiter only needs to distinguish identities (not read the token back), key on a fixed-size hash instead, e.g. |
||
| .extensions_mut() | ||
| .insert(RateLimitIdentity(identity_of(&credential))); | ||
| tracing::debug!( | ||
| path = %request_path, | ||
| method = %request_method, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,21 +10,27 @@ use axum::{ | |
| response::Response, | ||
| }; | ||
|
|
||
| /// Per-API-key token bucket rate limiter middleware. | ||
| /// Reads the X-API-Key header and checks the shared governor limiter. | ||
| /// Returns 429 if the key has exceeded its quota. | ||
| /// Per-identity token bucket rate limiter middleware. | ||
| /// Reads the `RateLimitIdentity` published by `unified_auth` after it validated | ||
| /// the credential, and checks the shared governor limiter. | ||
| /// Returns 429 if that identity has exceeded its quota. | ||
| pub async fn rate_limit( | ||
| State(state): State<AppState>, | ||
| request: Request, | ||
| next: Next, | ||
| ) -> Result<Response, AppError> { | ||
| // Extract key; fall back to IP or "anonymous" | ||
| let key = request | ||
| .headers() | ||
| .get("X-API-Key") | ||
| .and_then(|v| v.to_str().ok()) | ||
| .unwrap_or("anonymous") | ||
| .to_string(); | ||
| let identity = request | ||
| .extensions() | ||
|
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. Stale doc comments now that the key comes from the validated identity, not the header. The module doc above still reads: "Per-API-key token bucket rate limiter middleware. Reads the X-API-Key header and checks the shared governor limiter." That's no longer accurate — this now keys on the |
||
| .get::<crate::middleware::auth::RateLimitIdentity>() | ||
| .map(|id| id.0.clone()); | ||
|
|
||
| debug_assert!( | ||
| identity.is_some() || !state.config.auth_is_configured(), | ||
| "unified_auth must run before rate_limit: no RateLimitIdentity was published, \ | ||
| so every request would share one bucket" | ||
| ); | ||
|
|
||
| let key = identity.unwrap_or_else(|| "unauthenticated".to_string()); | ||
|
|
||
|
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. This fallback masks a fragile layering invariant. The fix only works because, in |
||
| match state.rate_limiter.check_key(&key) { | ||
| Ok(_) => Ok(next.run(request).await), | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,7 +14,7 @@ use std::sync::Arc; | |
| /// on every request, so real data must live behind Arc. | ||
| #[derive(Clone)] | ||
| pub struct AppState { | ||
| /// Per-API-key rate limiter (token bucket). | ||
| /// Per-identity rate limiter (token bucket), keyed on the validated credential. | ||
| pub rate_limiter: Arc<DefaultKeyedRateLimiter<String>>, | ||
|
|
||
| /// Shared reqwest connection pool. | ||
|
|
@@ -38,6 +38,7 @@ impl AppState { | |
| pub async fn new(config: crate::config::ServerConfig, logger: ArcLogger) -> Self { | ||
| let quota = Quota::per_second(NonZeroU32::new(config.rate_limit_per_sec.max(1)).unwrap()); | ||
| let rate_limiter = Arc::new(RateLimiter::keyed(quota)); | ||
| spawn_rate_limiter_gc(rate_limiter.clone()); | ||
|
|
||
| let http_client = reqwest::Client::builder() | ||
| .pool_max_idle_per_host(100) | ||
|
|
@@ -57,3 +58,13 @@ impl AppState { | |
| } | ||
| } | ||
| } | ||
|
|
||
| fn spawn_rate_limiter_gc(limiter: Arc<DefaultKeyedRateLimiter<String>>) { | ||
| tokio::spawn(async move { | ||
| let mut ticker = tokio::time::interval(std::time::Duration::from_secs(60)); | ||
| loop { | ||
| ticker.tick().await; | ||
| limiter.retain_recent(); | ||
|
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. This call won't compile against the pinned Also check the semantics once you add the argument: let window = std::time::Instant::now() - std::time::Duration::from_secs(120);
limiter.retain_recent(window);so recently-active keys (and the burst they've accrued) survive the sweep while abandoned keys are reclaimed. |
||
| } | ||
| }); | ||
| } | ||
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.
This embeds the configured secret itself into the rate-limiter key (
configured-key:<secret>). Simple-key mode has exactly one valid credential, so a constant like"configured-key"is equivalent — the key value adds no information. As written, the plaintext API key is stored as a DashMap key for the process lifetime (the GC never removes this single key), so the secret lingers in the limiter's map in addition toconfig. A constant (or a hash of the key) keeps the identity stable without duplicating the secret in a second place.