Skip to main content

structured_proxy/shield/
mod.rs

1//! Shield: request rate limiting.
2//!
3//! The proxy runs embedded on each service instance, so every decision is made
4//! locally with a per-instance GCRA shaper ([`store::GcraStore`]) that adds no
5//! blocking latency to the request path. When a shared store is configured, a
6//! background task reconciles counters across instances asynchronously to
7//! approximate a fleet-wide limit; the request path never blocks on it.
8//!
9//! A request is limited by the first [rule](matcher::CompiledRule) whose glob
10//! matches its path. The rule's key selects *who* is limited (client IP, a
11//! header value, or a validated JWT claim) and its profile selects *how much*.
12
13pub mod gcra;
14#[cfg(feature = "redis")]
15pub mod global;
16pub mod matcher;
17pub mod rate;
18pub mod resolve;
19pub mod store;
20pub mod window;
21
22use std::collections::HashMap;
23use std::sync::Arc;
24use std::time::Duration;
25
26use axum::extract::Request;
27use axum::http::header::RETRY_AFTER;
28use axum::http::{HeaderMap, HeaderName, HeaderValue};
29use axum::middleware::Next;
30use axum::response::Response;
31
32use crate::client_address::{ClientAddress, Resolution};
33use crate::config::ShieldConfig;
34use gcra::Verdict;
35use matcher::{CompiledProfile, CompiledRule, KeySource, Phase};
36use store::GcraStore;
37
38/// Compiled Shield rules, limit tiers, and the local GCRA store.
39pub struct Shield {
40    rules: Vec<CompiledRule>,
41    profiles: HashMap<String, CompiledProfile>,
42    /// Applied when a matched rule resolves no other limit.
43    default_profile: Option<CompiledProfile>,
44    /// Resolve a key's limit from validated JWT claims, when configured.
45    jwt_limits: Option<resolve::JwtLimits>,
46    /// Resolve a key's limit from an external service (cached, async).
47    limit_service: Option<Arc<resolve::LimitService>>,
48    /// Cross-instance reconciliation of the fleet-wide view (async, off the hot
49    /// path). Present only when a shared store is configured and compiled in.
50    #[cfg(feature = "redis")]
51    global: Option<Arc<global::GlobalCounters>>,
52    store: GcraStore,
53}
54
55impl Shield {
56    /// Build a Shield from config, or `None` when disabled / has no rules.
57    ///
58    /// # Errors
59    /// Returns an error string when a glob pattern, rate or profile reference
60    /// fails to compile.
61    pub fn build(config: &ShieldConfig) -> Result<Option<Arc<Self>>, String> {
62        if !config.enabled {
63            return Ok(None);
64        }
65        if config.rules.is_empty() {
66            // Fail loud rather than silently running unmetered: an upgrade that
67            // left an old `shield` schema (endpoint_classes / identifier_endpoints
68            // / redis_url) in place deserializes to zero rules, which would
69            // otherwise disable this security control while `enabled` is true.
70            return Err(
71                "shield.enabled is true but no rules are configured (note the schema: \
72                 profiles + rules + sync, not the older endpoint_classes/identifier_endpoints)"
73                    .to_string(),
74            );
75        }
76
77        let profiles = matcher::compile_profiles(&config.profiles)?;
78        let rules = matcher::compile_rules(&config.rules, &profiles)?;
79
80        let default_profile =
81            match &config.default_profile {
82                Some(name) => Some(*profiles.get(name).ok_or_else(|| {
83                    format!("default_profile references unknown profile {name:?}")
84                })?),
85                None => None,
86            };
87
88        let jwt_limits = config
89            .jwt_limits
90            .as_ref()
91            .map(resolve::JwtLimits::from_config);
92        let limit_service = match &config.limit_service {
93            Some(cfg) => Some(resolve::LimitService::build(cfg, profiles.clone())?),
94            None => None,
95        };
96
97        #[cfg(feature = "redis")]
98        let global = match &config.sync {
99            Some(sync) => {
100                let g = global::GlobalCounters::build(
101                    &sync.redis_url,
102                    Duration::from_millis(sync.interval_ms),
103                )?;
104                // Keep the fleet gate only if reconciliation actually started;
105                // otherwise fall back to per-instance limiting rather than gating
106                // on an estimate that would never be refreshed.
107                g.spawn().then_some(g)
108            }
109            None => None,
110        };
111        #[cfg(not(feature = "redis"))]
112        if config.sync.is_some() {
113            tracing::warn!(
114                "shield.sync is set but the `redis` feature is not compiled in; \
115                 staying local-only (per-instance limits)"
116            );
117        }
118
119        Ok(Some(Arc::new(Self {
120            rules,
121            profiles,
122            default_profile,
123            jwt_limits,
124            limit_service,
125            #[cfg(feature = "redis")]
126            global,
127            store: GcraStore::new(),
128        })))
129    }
130
131    /// The first rule in `phase` whose glob matches `path`. A path may match one
132    /// rule per phase; each phase enforces independently (two-phase by design:
133    /// a pre-auth IP/header rule and a post-auth claim rule can both apply).
134    fn match_rule(&self, path: &str, phase: Phase) -> Option<&CompiledRule> {
135        self.rules
136            .iter()
137            .find(|r| r.phase == phase && r.matcher.is_match(path))
138    }
139
140    /// Whether a rule runs in `phase`: a phase without one needs no layer.
141    pub(crate) fn enforces(&self, phase: Phase) -> bool {
142        self.rules.iter().any(|rule| rule.phase == phase)
143    }
144
145    /// Resolve the limit tier for a matched rule, in priority order: the JWT
146    /// itself (validated claims), then the external service (cached), then the
147    /// rule's pinned profile, then the default profile. `None` means no limit
148    /// applies and the request passes unmetered.
149    fn resolve_limit(
150        &self,
151        rule: &CompiledRule,
152        claims: Option<&serde_json::Value>,
153        identity: &str,
154    ) -> Option<CompiledProfile> {
155        if let (Some(jwt), Some(claims)) = (&self.jwt_limits, claims) {
156            if let Some(profile) = jwt.resolve(claims, &self.profiles) {
157                return Some(profile);
158            }
159        }
160        if let Some(service) = &self.limit_service {
161            if let Some(profile) = service.resolve(identity) {
162                return Some(profile);
163            }
164        }
165        self.static_profile(rule)
166    }
167
168    /// The rule's pinned profile, else the default profile.
169    fn static_profile(&self, rule: &CompiledRule) -> Option<CompiledProfile> {
170        rule.profile
171            .as_ref()
172            .and_then(|name| self.profiles.get(name))
173            .or(self.default_profile.as_ref())
174            .copied()
175    }
176}
177
178/// Pre-auth middleware: enforces rules that need no validated claims (IP /
179/// header keys). Layered outside auth so anonymous floods are shed before any
180/// signature verification.
181pub async fn pre_auth_middleware(
182    axum::extract::State(shield): axum::extract::State<Arc<Shield>>,
183    request: Request,
184    next: Next,
185) -> Response {
186    enforce(&shield, Phase::PreAuth, request, next).await
187}
188
189/// Post-auth middleware: enforces rules keyed by a validated JWT claim. Layered
190/// inside auth so the verified claims are available on the request.
191pub async fn post_auth_middleware(
192    axum::extract::State(shield): axum::extract::State<Arc<Shield>>,
193    request: Request,
194    next: Next,
195) -> Response {
196    enforce(&shield, Phase::PostAuth, request, next).await
197}
198
199/// Match a phase's rule for the request, apply its limit, and attach headers.
200async fn enforce(shield: &Shield, phase: Phase, request: Request, next: Next) -> Response {
201    match shield.decide(phase, &request) {
202        Decision::Pass => next.run(request).await,
203        Decision::Reject(response) => response,
204        Decision::Report(report) => {
205            let mut response = next.run(request).await;
206            report.apply(&mut response);
207            response
208        }
209    }
210}
211
212/// What a phase's limit makes of a request.
213pub(crate) enum Decision {
214    /// No limit applies.
215    Pass,
216    /// Over the limit: the answer.
217    Reject(Response),
218    /// Admitted: the budget its response reports.
219    Report(Report),
220}
221
222/// The budget an admitted request's response reports.
223pub(crate) struct Report {
224    limit: u64,
225    remaining: u64,
226    verdict: Verdict,
227}
228
229impl Report {
230    /// Report the budget on `response`: the tightest one across phases. With
231    /// defense-in-depth (a pre-auth and a post-auth rule on the same path), an
232    /// inner limiter may already have set headers on the way out; they are
233    /// overwritten only when this (outer) phase's remaining is smaller, so the
234    /// client always sees the budget that will bite first. An inner rejection
235    /// carries remaining 0, so it is never overwritten.
236    pub(crate) fn apply(&self, response: &mut Response) {
237        maybe_tighten_rate_headers(
238            response.headers_mut(),
239            self.limit,
240            self.remaining,
241            &self.verdict,
242        );
243    }
244}
245
246impl Shield {
247    /// Match a phase's rule for `request` and charge its limit.
248    pub(crate) fn decide<B>(&self, phase: Phase, request: &http::Request<B>) -> Decision {
249        let path = request.uri().path();
250        let Some(rule) = self.match_rule(path, phase) else {
251            return Decision::Pass;
252        };
253        self.charge(rule, request)
254    }
255
256    /// Charge `request` to the limit of `rule`, which its path matched.
257    fn charge<B>(&self, rule: &CompiledRule, request: &http::Request<B>) -> Decision {
258        // The proxy resolved the client address before any guard. Shield used on
259        // its own, in a router of the embedder's, gets it from a
260        // `ClientAddressLayer` in front of it; without one it falls back to what
261        // resolution gives with no trusted proxy: the peer an axum server records.
262        let unresolved;
263        let client = match request.extensions().get::<ClientAddress>() {
264            Some(client) => client,
265            None => {
266                let peer = request
267                    .extensions()
268                    .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
269                    .map(|ci| ci.0);
270                unresolved = ClientAddress::from_peer(peer);
271                &unresolved
272            }
273        };
274        let claims = request
275            .extensions()
276            .get::<crate::auth::ValidatedClaims>()
277            .map(|c| c.0.as_ref());
278        let key = rule_key(
279            &rule.fingerprint,
280            &rule.key,
281            client,
282            request.headers(),
283            claims,
284        );
285
286        // The limit service resolves per-principal, so it needs the real identity;
287        // the store / shared counter only need the de-identified `store` key.
288        let Some(profile) = self.resolve_limit(rule, claims, key.identity.as_str()) else {
289            // No limit resolves for this rule (JWT/service/profile/default all
290            // absent): allow the request unmetered.
291            return Decision::Pass;
292        };
293
294        // Fleet gate first (read-only, cached), so the local shaper is not charged
295        // for a request the fleet-wide budget will reject. The fleet budget is the
296        // sustained rate (`profile.limit`); `burst` is deliberately a per-instance
297        // smoothing allowance, not a fleet-wide entitlement (honouring it fleet-wide
298        // would multiply the effective limit by N). A single instance's burst is
299        // therefore capped by the shared budget when the fleet is near it.
300        //
301        // Consequence for a `burst > limit` profile (e.g. `rate: "1/min", burst: 5`):
302        // under reconciliation the shared counter caps the key at the sustained
303        // `limit`, so the extra burst headroom that a local-only deployment would
304        // allow is not granted fleet-wide. This is intentional, not an oversight:
305        // gating on `burst` instead would let the fleet sustain `burst`-per-window,
306        // loosening the sustained cap by the burst factor. The conservative choice
307        // (never over-admit the fleet's sustained budget) wins; the local GCRA still
308        // smooths per-instance traffic.
309        #[cfg(feature = "redis")]
310        let fleet_remaining = self
311            .global
312            .as_ref()
313            .map(|g| g.fleet_remaining(key.store.as_str(), profile.limit, profile.window));
314        #[cfg(feature = "redis")]
315        if fleet_remaining == Some(0) {
316            return Decision::Reject(global_reject(profile.limit, profile.window));
317        }
318
319        // The store key intentionally excludes the profile's numbers, so if a key's
320        // resolved tier changes (a JWT/service tier upgrade), the existing TAT is
321        // reused with the new emission interval. The TAT is an absolute time, so this
322        // only causes a brief transient at the change and self-corrects within one
323        // window. Keying by the tier's numbers instead would reset the budget on
324        // every tier flip, which a client could exploit to shed its own limit.
325        let verdict = self.store.check(key.store.as_str(), &profile.gcra);
326        if !verdict.allowed {
327            return Decision::Reject(too_many_requests(
328                profile.limit,
329                verdict.remaining,
330                &verdict,
331            ));
332        }
333
334        // Report the tighter of the local and (when reconciled) fleet budgets, so a
335        // client near the fleet cap isn't told it has ample local room.
336        #[cfg(not(feature = "redis"))]
337        let (reported, header_verdict) = reconciled_headers(verdict, None, profile.window);
338        #[cfg(feature = "redis")]
339        let (reported, header_verdict) = {
340            // Record the admit for the next reconciliation push (whenever the fleet
341            // gate is active, i.e. `fleet_remaining` was computed).
342            if fleet_remaining.is_some() {
343                if let Some(global) = &self.global {
344                    global.record(key.store.as_str(), profile.window);
345                }
346            }
347            reconciled_headers(verdict, fleet_remaining, profile.window)
348        };
349        Decision::Report(Report {
350            limit: profile.limit,
351            remaining: reported,
352            verdict: header_verdict,
353        })
354    }
355}
356
357/// Combine the local GCRA `verdict` with the optional fleet remaining into the
358/// reported remaining and the verdict whose reset drives the headers. The count
359/// is the tighter of local and fleet, minus this admit (`fr - 1`).
360fn reconciled_headers(
361    verdict: Verdict,
362    fleet_remaining: Option<u64>,
363    window: Duration,
364) -> (u64, Verdict) {
365    let mut reported = verdict.remaining;
366    let mut hv = verdict;
367    if let Some(fr) = fleet_remaining {
368        let fleet_r = fr.saturating_sub(1);
369        if fleet_r <= reported {
370            // The fleet budget binds. Advertise the fleet-derived reset (the
371            // shared sliding-window estimate can stay saturated far longer than
372            // this instance's local GCRA), so a client pacing off the allowed
373            // response does not retry before the window decays and immediately
374            // hit the fleet gate. Widen, never shrink: keep the local reset if it
375            // is already the longer wait.
376            reported = fleet_r;
377            let backoff = fleet_backoff(window);
378            hv.reset_after = hv.reset_after.max(backoff);
379            hv.retry_after = hv.retry_after.max(backoff);
380        }
381    }
382    (reported, hv)
383}
384
385/// Poll interval for a client blocked (or nearly blocked) by the fleet gate: a
386/// tenth of the window, at least 1s. The fleet's sliding-window estimate decays
387/// continuously rather than freeing at an epoch boundary, so this is a retry
388/// cadence, not a wait-to-boundary.
389fn fleet_backoff(window: Duration) -> Duration {
390    (window / 10).max(Duration::from_secs(1))
391}
392
393/// Set the `RateLimit-*` headers unless an inner limiter already advertised a
394/// budget that binds at least as hard, which must reach the client intact.
395/// "Binds harder" is a smaller `remaining`, and on a `remaining` tie the larger
396/// `reset` wins: with both phases at `remaining=0`, a client pacing off the
397/// headers must see the longest wait (e.g. an hourly IP cap over a per-minute
398/// principal cap), or it retries early and immediately hits the outer limit.
399fn maybe_tighten_rate_headers(
400    headers: &mut HeaderMap,
401    limit: u64,
402    remaining: u64,
403    verdict: &Verdict,
404) {
405    let header_u64 = |name: &HeaderName| {
406        headers
407            .get(name)
408            .and_then(|v| v.to_str().ok())
409            .and_then(|v| v.parse::<u64>().ok())
410    };
411    let keep_inner = match header_u64(&RATELIMIT_REMAINING) {
412        Some(inner) if inner < remaining => true,
413        // Tie on remaining: keep the inner headers only if their reset is at
414        // least as long as this phase's, so the longer-binding budget survives.
415        Some(inner) if inner == remaining => {
416            header_u64(&RATELIMIT_RESET).unwrap_or(0) >= secs_ceil(verdict.reset_after)
417        }
418        _ => false,
419    };
420    if !keep_inner {
421        attach_rate_headers(headers, limit, remaining, verdict);
422        // On an error response the rejecting layer already set `Retry-After`. We
423        // just replaced the budget with a longer-binding one, so widen
424        // `Retry-After` to that reset too; otherwise the client retries after the
425        // overwritten (shorter) wait and immediately hits this binding budget.
426        // Only ever widen: a 200 has no `Retry-After` to touch, and an already
427        // longer wait is left intact.
428        if let Some(current) = headers
429            .get(RETRY_AFTER)
430            .and_then(|v| v.to_str().ok())
431            .and_then(|v| v.parse::<u64>().ok())
432        {
433            let reset = secs_ceil(verdict.reset_after);
434            if reset > current {
435                headers.insert(RETRY_AFTER, HeaderValue::from(reset));
436            }
437        }
438    }
439}
440
441/// A `429` for a request rejected by the fleet-wide gate. The sliding-window
442/// estimate decays continuously (it does not free capacity at the epoch
443/// boundary), so `Retry-After` is a modest poll interval rather than the time to
444/// the boundary, which a client could wait out and still be rejected.
445#[cfg(feature = "redis")]
446fn global_reject(limit: u64, window: Duration) -> Response {
447    let backoff = fleet_backoff(window);
448    let verdict = Verdict {
449        allowed: false,
450        new_tat: Duration::ZERO,
451        remaining: 0,
452        retry_after: backoff,
453        reset_after: backoff,
454    };
455    too_many_requests(limit, 0, &verdict)
456}
457
458/// The keys a matched rule derives for one request.
459struct RuleKey<'r> {
460    /// De-identified key for the local store and shared counter: the rule's
461    /// stable fingerprint, the source tag, and a hash of the value. Raw client
462    /// values (API keys, principals, IPs) never reach the shared store or its
463    /// logs. Deterministic across instances so reconciliation keys agree.
464    store: StoreKey,
465    /// The raw identity for per-principal limit-service resolution (the service
466    /// must see the real principal to resolve its tier). Not persisted.
467    identity: Identity<'r>,
468}
469
470/// A store key, `fingerprint:tag:hash`, held inline: the fingerprint and the
471/// hash are 32 hex digits each and the tag at most three letters.
472struct StoreKey {
473    bytes: [u8; 72],
474    len: usize,
475}
476
477impl StoreKey {
478    fn new(fingerprint: &str, tag: &str, hash: &[u8; 32]) -> Self {
479        let mut key = Self {
480            bytes: [0; 72],
481            len: 0,
482        };
483        for part in [fingerprint.as_bytes(), b":", tag.as_bytes(), b":", hash] {
484            key.bytes[key.len..key.len + part.len()].copy_from_slice(part);
485            key.len += part.len();
486        }
487        key
488    }
489
490    fn as_str(&self) -> &str {
491        std::str::from_utf8(&self.bytes[..self.len]).expect("hex digits, a tag and colons")
492    }
493}
494
495/// The value a rule keys a request's limit by, borrowed from the request where
496/// it can be.
497enum Identity<'r> {
498    Address(AddressText),
499    Borrowed(&'r str),
500    Owned(String),
501}
502
503impl Identity<'_> {
504    fn as_str(&self) -> &str {
505        match self {
506            Identity::Address(address) => address.as_str(),
507            Identity::Borrowed(text) => text,
508            Identity::Owned(text) => text,
509        }
510    }
511}
512
513/// An IP address as text, held inline: an IPv6 address is at most 45
514/// characters (RFC 4291 §2.2, with an embedded IPv4 address).
515struct AddressText {
516    bytes: [u8; 45],
517    len: usize,
518}
519
520impl AddressText {
521    fn new(ip: std::net::IpAddr) -> Self {
522        use std::fmt::Write;
523        let mut text = Self {
524            bytes: [0; 45],
525            len: 0,
526        };
527        write!(text, "{ip}").expect("an IP address fits in 45 characters");
528        text
529    }
530
531    fn as_str(&self) -> &str {
532        std::str::from_utf8(&self.bytes[..self.len]).expect("an address is written as text")
533    }
534}
535
536impl std::fmt::Write for AddressText {
537    fn write_str(&mut self, s: &str) -> std::fmt::Result {
538        let end = self.len + s.len();
539        let slot = self.bytes.get_mut(self.len..end).ok_or(std::fmt::Error)?;
540        slot.copy_from_slice(s.as_bytes());
541        self.len = end;
542        Ok(())
543    }
544}
545
546/// Derive the store key and resolution identity for a matched rule. Every source
547/// falls back to the client address when its value is absent, so a limit can't
548/// be dodged by omitting a header or authenticating anonymously (subject to the
549/// rule's phase: a `jwt_claim` rule only runs post-auth).
550fn rule_key<'r>(
551    fingerprint: &str,
552    key: &KeySource,
553    client: &ClientAddress,
554    headers: &'r HeaderMap,
555    claims: Option<&serde_json::Value>,
556) -> RuleKey<'r> {
557    let (tag, identity) = match key {
558        KeySource::Ip => ("ip", address_identity(client)),
559        KeySource::Header(name) => match header_str(headers, name) {
560            Some(v) => ("hdr", Identity::Borrowed(v)),
561            None => ("ip", address_identity(client)),
562        },
563        KeySource::JwtClaim(claim) => match claims.and_then(|c| resolve::claim_str(c, claim)) {
564            Some(v) => ("jwt", Identity::Owned(v)),
565            None => ("ip", address_identity(client)),
566        },
567    };
568    let hash = matcher::short_hash_hex(identity.as_str().as_bytes());
569    RuleKey {
570        store: StoreKey::new(fingerprint, tag, &hash),
571        identity,
572    }
573}
574
575/// The identity a client address keys a limit by: the address, or one shared
576/// bucket per reason none resolved. A request that cannot name its client
577/// shares its budget with every other such request rather than escaping the
578/// limit, and a proxy's own address never stands in for the client's.
579fn address_identity(client: &ClientAddress) -> Identity<'static> {
580    match client.resolution() {
581        Resolution::Peer(ip) | Resolution::Forwarded(ip) => Identity::Address(AddressText::new(ip)),
582        Resolution::Invalid(_) => Identity::Borrowed("invalid"),
583        Resolution::Unavailable => Identity::Borrowed("unknown"),
584    }
585}
586
587/// Trimmed, non-empty value of a header.
588fn header_str<'h>(headers: &'h HeaderMap, name: &str) -> Option<&'h str> {
589    headers
590        .get(name)
591        .and_then(|v| v.to_str().ok())
592        .map(str::trim)
593        .filter(|s| !s.is_empty())
594}
595
596static RATELIMIT_LIMIT: HeaderName = HeaderName::from_static("ratelimit-limit");
597static RATELIMIT_REMAINING: HeaderName = HeaderName::from_static("ratelimit-remaining");
598static RATELIMIT_RESET: HeaderName = HeaderName::from_static("ratelimit-reset");
599
600/// Attach the draft-ietf `RateLimit-*` headers describing the remaining budget.
601/// `remaining` is passed explicitly (rather than read from the verdict) so the
602/// caller can report the tighter of the local and fleet budgets.
603fn attach_rate_headers(headers: &mut HeaderMap, limit: u64, remaining: u64, verdict: &Verdict) {
604    headers.insert(RATELIMIT_LIMIT.clone(), HeaderValue::from(limit));
605    headers.insert(RATELIMIT_REMAINING.clone(), HeaderValue::from(remaining));
606    headers.insert(
607        RATELIMIT_RESET.clone(),
608        HeaderValue::from(secs_ceil(verdict.reset_after)),
609    );
610}
611
612/// A `429` response carrying the rate-limit headers plus `Retry-After`.
613fn too_many_requests(limit: u64, remaining: u64, verdict: &Verdict) -> Response {
614    let mut response = crate::guard::reject(tonic::Code::ResourceExhausted, "rate limit exceeded");
615    let headers = response.headers_mut();
616    attach_rate_headers(headers, limit, remaining, verdict);
617    headers.insert(
618        RETRY_AFTER,
619        HeaderValue::from(secs_ceil(verdict.retry_after)),
620    );
621    response
622}
623
624/// Whole seconds, rounded up, for `Retry-After` / `RateLimit-Reset` (never report
625/// `0` for a non-zero wait).
626fn secs_ceil(d: Duration) -> u64 {
627    // Round up from nanoseconds, not truncated millis: a sub-millisecond wait
628    // must still report at least one second, never zero.
629    let nanos = d.as_nanos();
630    u64::try_from(nanos.div_ceil(1_000_000_000)).unwrap_or(u64::MAX)
631}
632
633#[cfg(test)]
634mod tests;