cubeapi: key the rate limiter on the validated identity, not an unvalidated header - #1380
cubeapi: key the rate limiter on the validated identity, not an unvalidated header#1380dwin-gharibi wants to merge 5 commits into
Conversation
…e validated identity, not an unvalidated header
| .and_then(|v| v.to_str().ok()) | ||
| .unwrap_or("anonymous") | ||
| .to_string(); | ||
| .extensions() |
There was a problem hiding this comment.
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 RateLimitIdentity extension published by unified_auth (which is itself keyed on the validated credential). Same for AppState::rate_limiter's "Per-API-key rate limiter" field doc. Worth a one-line update so the docs don't mislead a future reader into re-adding header-based keying.
Review: cubeapi — key the rate limiter on the validated identity (PR #1380)AI-generated review. Reviewed against the base branch workspace; the diff was not truncated. I could not run SummaryThe core fix is sound and well-motivated: the limiter now buckets on the credential that There is one blocking issue (the GC call in Blocking1.
|
…key and correct the limiter docs
| .get::<crate::middleware::auth::RateLimitIdentity>() | ||
| .map(|id| id.0.clone()) | ||
| .unwrap_or_else(|| "unauthenticated".to_string()); | ||
|
|
There was a problem hiding this comment.
This fallback masks a fragile layering invariant. The fix only works because, in with_auth_and_rate_limit (routes.rs:214), unified_auth is the outermost of the two layers (in axum the last .layer() added runs first), so the RateLimitIdentity extension is always populated by the time this runs. If those two .layer() calls are ever reordered, every request silently lands in this single shared "unauthenticated" bucket — re-introducing exactly the multi-tenant starvation this PR fixes — with no error, no log, and no test catching it. Current code is correct, but consider a comment on with_auth_and_rate_limit (or a debug assertion) documenting that unified_auth must run before rate_limit.
…irst and correct the per-identity docs
| 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.
This call won't compile against the pinned governor 0.6.3 (CubeAPI/Cargo.lock). In 0.6.x, KeyedRateLimiter::retain_recent is pub fn retain_recent(&self, up_to: Instant) -> usize — it takes an Instant argument, so limiter.retain_recent() is an arity error (E0061).
Also check the semantics once you add the argument: retain_recent(up_to) retains only keys whose last update is after up_to. Passing Instant::now() at each tick drops every key (every key's last update is before "now"), wiping the whole map — i.e. every client's bucket resets once a minute. That defeats the limiter for clients that are right at their burst boundary when the tick fires (they get a fresh full bucket). Pass a retention window instead, e.g.
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.
| "Invalid API key or token".to_string(), | ||
| )); | ||
| } | ||
| request.extensions_mut().insert(RateLimitIdentity(format!( |
There was a problem hiding this comment.
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 to config. A constant (or a hash of the key) keeps the identity stable without duplicating the secret in a second place.
| }; | ||
|
|
||
| if callback_resp.status().as_u16() == 200 { | ||
| request |
There was a problem hiding this comment.
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. format!("bearer:{}", sha256(token)) / format!("apikey:{}", sha256(key)). That preserves per-tenant separation while bounding per-key memory and not retaining plaintext secrets. (This is also the shape of the unbounded-growth attack the GC in state.rs is meant to bound — hashing makes each entry small regardless.)
Closes #1379.
Motivation
The limiter bucketed on the raw
X-API-Keyheader. Becauseextract_credentialprefersAuthorization: Bearer, that header is never validated when a Bearer token is present — but it stillchose the bucket. Rotating it therefore gave a fresh full-quota bucket per request and the limit never
applied. Clients sending no
X-API-Keyall shared a single"anonymous"bucket, so in callback(multi-tenant) mode any one tenant could starve the rest. And the keyed state store was never reclaimed.
What this changes
1.
middleware/auth.rs— the auth middleware publishes the identity it validated. A newRateLimitIdentity(String)is inserted into the request extensions once the credential has been accepted,in both modes:
The identity is prefixed (
bearer:/apikey:) so the two credential kinds cannot collide.2.
middleware/rate_limit.rs— the limiter reads that extension instead of a header, falling back toa single
"unauthenticated"bucket if it is somehow absent. In practice it never is:rate_limitis onlyever layered together with
unified_auth, andunified_authruns first.3.
middleware/auth.rs— the API key comparison is now constant-time.provided != expected_keyshort-circuits at the first differing byte, which leaks the key one byte at a time to a patient attacker.
Replaced with a length-check plus an XOR-accumulate loop. CubeOps already does this
(
subtle.ConstantTimeCompare), so the two services now agree.4.
state.rs— a background task callsrate_limiter.retain_recent()every 60s, so the DashMap nolonger grows without bound.
No comment changes.
Testing
Re-ran the probe that originally demonstrated the bypass, against the real binary with
CUBE_API_KEY=supersecret --rate-limit-per-sec 3, 30 requests per case:X-API-KeyX-API-KeyonlyCase B is the fix. Case C confirms API-key clients are still limited, and case A that Bearer clients are
too — all three shapes now behave the same.
CubeAPI's own suite:
CI gates checked locally:
cargo fmt --check— clean (fmt-check).cargo build— clean.cargo clippy --all-targets— no new warnings. The twofield_reassign_with_defaulthits reported inauth.rsare the pre-existing ones in that file's test module; their line numbers moved because thischange inserts code above them.
Behaviour change worth noting
Bearer clients are now limited per token rather than sharing one global bucket. That is the intended
behaviour, but it does mean a deployment that was unknowingly relying on the shared-bucket accounting will
see different 429 patterns. Concretely: a single Bearer client that previously consumed the shared quota
now gets its own, so aggregate throughput across many Bearer clients goes up, while an individual abusive
client is now actually constrained.
Not fixed here
extract_credentialmatches theBearerscheme case-sensitively, so a spec-compliantauthorization: bearer <token>is rejected (RFC 7235 §2.1 makes the scheme case-insensitive). I confirmedit still reproduces on this branch (5/5 requests → 401) and left it alone: it is a separate defect with its
own issue, and mixing an interop fix into a rate-limiting fix would muddy both.