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::{HeaderMap, StatusCode};
28use axum::middleware::Next;
29use axum::response::{IntoResponse, Response};
30use axum::Json;
31
32use crate::config::ShieldConfig;
33use gcra::Verdict;
34use matcher::{CompiledProfile, CompiledRule, KeySource, Phase};
35use store::GcraStore;
36
37/// Compiled Shield rules, limit tiers, and the local GCRA store.
38pub struct Shield {
39    rules: Vec<CompiledRule>,
40    profiles: HashMap<String, CompiledProfile>,
41    /// Applied when a matched rule resolves no other limit.
42    default_profile: Option<CompiledProfile>,
43    /// Resolve a key's limit from validated JWT claims, when configured.
44    jwt_limits: Option<resolve::JwtLimits>,
45    /// Resolve a key's limit from an external service (cached, async).
46    limit_service: Option<Arc<resolve::LimitService>>,
47    /// Cross-instance reconciliation of the fleet-wide view (async, off the hot
48    /// path). Present only when a shared store is configured and compiled in.
49    #[cfg(feature = "redis")]
50    global: Option<Arc<global::GlobalCounters>>,
51    store: GcraStore,
52    /// CIDR ranges whose `X-Forwarded-For` / `X-Real-IP` headers we trust.
53    trusted_proxies: Vec<ipnet::IpNet>,
54}
55
56impl Shield {
57    /// Build a Shield from config, or `None` when disabled / has no rules.
58    ///
59    /// # Errors
60    /// Returns an error string when a glob pattern, rate, profile reference, or
61    /// trusted-proxy CIDR fails to compile.
62    pub fn build(config: &ShieldConfig) -> Result<Option<Arc<Self>>, String> {
63        if !config.enabled {
64            return Ok(None);
65        }
66        if config.rules.is_empty() {
67            // Fail loud rather than silently running unmetered: an upgrade that
68            // left an old `shield` schema (endpoint_classes / identifier_endpoints
69            // / redis_url) in place deserializes to zero rules, which would
70            // otherwise disable this security control while `enabled` is true.
71            return Err(
72                "shield.enabled is true but no rules are configured (note the schema: \
73                 profiles + rules + sync, not the older endpoint_classes/identifier_endpoints)"
74                    .to_string(),
75            );
76        }
77
78        let profiles = matcher::compile_profiles(&config.profiles)?;
79        let rules = matcher::compile_rules(&config.rules, &profiles)?;
80
81        let default_profile =
82            match &config.default_profile {
83                Some(name) => Some(*profiles.get(name).ok_or_else(|| {
84                    format!("default_profile references unknown profile {name:?}")
85                })?),
86                None => None,
87            };
88
89        let trusted_proxies = config
90            .trusted_proxies
91            .iter()
92            .map(|s| parse_cidr(s))
93            .collect::<Result<Vec<_>, _>>()?;
94
95        let jwt_limits = config
96            .jwt_limits
97            .as_ref()
98            .map(resolve::JwtLimits::from_config);
99        let limit_service = match &config.limit_service {
100            Some(cfg) => Some(resolve::LimitService::build(cfg, profiles.clone())?),
101            None => None,
102        };
103
104        #[cfg(feature = "redis")]
105        let global = match &config.sync {
106            Some(sync) => {
107                let g = global::GlobalCounters::build(
108                    &sync.redis_url,
109                    Duration::from_millis(sync.interval_ms),
110                )?;
111                // Keep the fleet gate only if reconciliation actually started;
112                // otherwise fall back to per-instance limiting rather than gating
113                // on an estimate that would never be refreshed.
114                g.spawn().then_some(g)
115            }
116            None => None,
117        };
118        #[cfg(not(feature = "redis"))]
119        if config.sync.is_some() {
120            tracing::warn!(
121                "shield.sync is set but the `redis` feature is not compiled in; \
122                 staying local-only (per-instance limits)"
123            );
124        }
125
126        Ok(Some(Arc::new(Self {
127            rules,
128            profiles,
129            default_profile,
130            jwt_limits,
131            limit_service,
132            #[cfg(feature = "redis")]
133            global,
134            store: GcraStore::new(),
135            trusted_proxies,
136        })))
137    }
138
139    /// The first rule in `phase` whose glob matches `path`. A path may match one
140    /// rule per phase; each phase enforces independently (two-phase by design:
141    /// a pre-auth IP/header rule and a post-auth claim rule can both apply).
142    fn match_rule(&self, path: &str, phase: Phase) -> Option<&CompiledRule> {
143        self.rules
144            .iter()
145            .find(|r| r.phase == phase && r.matcher.is_match(path))
146    }
147
148    /// Resolve the limit tier for a matched rule, in priority order: the JWT
149    /// itself (validated claims), then the external service (cached), then the
150    /// rule's pinned profile, then the default profile. `None` means no limit
151    /// applies and the request passes unmetered.
152    fn resolve_limit(
153        &self,
154        rule: &CompiledRule,
155        claims: Option<&serde_json::Value>,
156        identity: &str,
157    ) -> Option<CompiledProfile> {
158        if let (Some(jwt), Some(claims)) = (&self.jwt_limits, claims) {
159            if let Some(profile) = jwt.resolve(claims, &self.profiles) {
160                return Some(profile);
161            }
162        }
163        if let Some(service) = &self.limit_service {
164            if let Some(profile) = service.resolve(identity) {
165                return Some(profile);
166            }
167        }
168        self.static_profile(rule)
169    }
170
171    /// The rule's pinned profile, else the default profile.
172    fn static_profile(&self, rule: &CompiledRule) -> Option<CompiledProfile> {
173        rule.profile
174            .as_ref()
175            .and_then(|name| self.profiles.get(name))
176            .or(self.default_profile.as_ref())
177            .copied()
178    }
179}
180
181/// Pre-auth middleware: enforces rules that need no validated claims (IP /
182/// header keys). Layered outside auth so anonymous floods are shed before any
183/// signature verification.
184pub async fn pre_auth_middleware(
185    axum::extract::State(shield): axum::extract::State<Arc<Shield>>,
186    request: Request,
187    next: Next,
188) -> Response {
189    enforce(&shield, Phase::PreAuth, request, next).await
190}
191
192/// Post-auth middleware: enforces rules keyed by a validated JWT claim. Layered
193/// inside auth so the verified claims are available on the request.
194pub async fn post_auth_middleware(
195    axum::extract::State(shield): axum::extract::State<Arc<Shield>>,
196    request: Request,
197    next: Next,
198) -> Response {
199    enforce(&shield, Phase::PostAuth, request, next).await
200}
201
202/// Match a phase's rule for the request, apply its limit, and attach headers.
203async fn enforce(shield: &Shield, phase: Phase, request: Request, next: Next) -> Response {
204    let path = request.uri().path();
205    let Some(rule) = shield.match_rule(path, phase) else {
206        return next.run(request).await;
207    };
208
209    let peer = request
210        .extensions()
211        .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
212        .map(|ci| ci.0.ip());
213    let client = client_ip(peer, request.headers(), &shield.trusted_proxies);
214    let claims = request
215        .extensions()
216        .get::<crate::auth::ValidatedClaims>()
217        .map(|c| c.0.as_ref());
218    let key = rule_key(
219        &rule.fingerprint,
220        &rule.key,
221        &client,
222        request.headers(),
223        claims,
224    );
225
226    // The limit service resolves per-principal, so it needs the real identity;
227    // the store / shared counter only need the de-identified `store` key.
228    let Some(profile) = shield.resolve_limit(rule, claims, &key.identity) else {
229        // No limit resolves for this rule (JWT/service/profile/default all
230        // absent): allow the request unmetered.
231        return next.run(request).await;
232    };
233
234    // Fleet gate first (read-only, cached), so the local shaper is not charged
235    // for a request the fleet-wide budget will reject. The fleet budget is the
236    // sustained rate (`profile.limit`); `burst` is deliberately a per-instance
237    // smoothing allowance, not a fleet-wide entitlement (honouring it fleet-wide
238    // would multiply the effective limit by N). A single instance's burst is
239    // therefore capped by the shared budget when the fleet is near it.
240    //
241    // Consequence for a `burst > limit` profile (e.g. `rate: "1/min", burst: 5`):
242    // under reconciliation the shared counter caps the key at the sustained
243    // `limit`, so the extra burst headroom that a local-only deployment would
244    // allow is not granted fleet-wide. This is intentional, not an oversight:
245    // gating on `burst` instead would let the fleet sustain `burst`-per-window,
246    // loosening the sustained cap by the burst factor. The conservative choice
247    // (never over-admit the fleet's sustained budget) wins; the local GCRA still
248    // smooths per-instance traffic.
249    #[cfg(feature = "redis")]
250    let fleet_remaining = shield
251        .global
252        .as_ref()
253        .map(|g| g.fleet_remaining(&key.store, profile.limit, profile.window));
254    #[cfg(feature = "redis")]
255    if fleet_remaining == Some(0) {
256        return global_reject(profile.limit, profile.window);
257    }
258
259    // The store key intentionally excludes the profile's numbers, so if a key's
260    // resolved tier changes (a JWT/service tier upgrade), the existing TAT is
261    // reused with the new emission interval. The TAT is an absolute time, so this
262    // only causes a brief transient at the change and self-corrects within one
263    // window. Keying by the tier's numbers instead would reset the budget on
264    // every tier flip, which a client could exploit to shed its own limit.
265    let verdict = shield.store.check(&key.store, &profile.gcra);
266    if !verdict.allowed {
267        return too_many_requests(profile.limit, verdict.remaining, &verdict);
268    }
269
270    // Report the tighter of the local and (when reconciled) fleet budgets, so a
271    // client near the fleet cap isn't told it has ample local room.
272    #[cfg(not(feature = "redis"))]
273    let (reported, header_verdict) = reconciled_headers(verdict, None, profile.window);
274    #[cfg(feature = "redis")]
275    let (reported, header_verdict) = {
276        // Record the admit for the next reconciliation push (whenever the fleet
277        // gate is active, i.e. `fleet_remaining` was computed).
278        if fleet_remaining.is_some() {
279            if let Some(global) = &shield.global {
280                global.record(&key.store, profile.window);
281            }
282        }
283        reconciled_headers(verdict, fleet_remaining, profile.window)
284    };
285
286    let mut response = next.run(request).await;
287    // Report the tightest budget across phases. With defense-in-depth (a pre-auth
288    // and a post-auth rule on the same path), an inner limiter may already have
289    // set headers on the way out; overwrite them only when this (outer) phase's
290    // remaining is smaller, so the client always sees the budget that will bite
291    // first. An inner rejection carries remaining 0, so it is never overwritten.
292    maybe_tighten_rate_headers(
293        response.headers_mut(),
294        profile.limit,
295        reported,
296        &header_verdict,
297    );
298    response
299}
300
301/// Combine the local GCRA `verdict` with the optional fleet remaining into the
302/// reported remaining and the verdict whose reset drives the headers. The count
303/// is the tighter of local and fleet, minus this admit (`fr - 1`).
304fn reconciled_headers(
305    verdict: Verdict,
306    fleet_remaining: Option<u64>,
307    window: Duration,
308) -> (u64, Verdict) {
309    let mut reported = verdict.remaining;
310    let mut hv = verdict;
311    if let Some(fr) = fleet_remaining {
312        let fleet_r = fr.saturating_sub(1);
313        if fleet_r <= reported {
314            // The fleet budget binds. Advertise the fleet-derived reset (the
315            // shared sliding-window estimate can stay saturated far longer than
316            // this instance's local GCRA), so a client pacing off the allowed
317            // response does not retry before the window decays and immediately
318            // hit the fleet gate. Widen, never shrink: keep the local reset if it
319            // is already the longer wait.
320            reported = fleet_r;
321            let backoff = fleet_backoff(window);
322            hv.reset_after = hv.reset_after.max(backoff);
323            hv.retry_after = hv.retry_after.max(backoff);
324        }
325    }
326    (reported, hv)
327}
328
329/// Poll interval for a client blocked (or nearly blocked) by the fleet gate: a
330/// tenth of the window, at least 1s. The fleet's sliding-window estimate decays
331/// continuously rather than freeing at an epoch boundary, so this is a retry
332/// cadence, not a wait-to-boundary.
333fn fleet_backoff(window: Duration) -> Duration {
334    (window / 10).max(Duration::from_secs(1))
335}
336
337/// Set the `RateLimit-*` headers unless an inner limiter already advertised a
338/// budget that binds at least as hard, which must reach the client intact.
339/// "Binds harder" is a smaller `remaining`, and on a `remaining` tie the larger
340/// `reset` wins: with both phases at `remaining=0`, a client pacing off the
341/// headers must see the longest wait (e.g. an hourly IP cap over a per-minute
342/// principal cap), or it retries early and immediately hits the outer limit.
343fn maybe_tighten_rate_headers(
344    headers: &mut HeaderMap,
345    limit: u64,
346    remaining: u64,
347    verdict: &Verdict,
348) {
349    let header_u64 = |name: &str| {
350        headers
351            .get(name)
352            .and_then(|v| v.to_str().ok())
353            .and_then(|v| v.parse::<u64>().ok())
354    };
355    let keep_inner = match header_u64("ratelimit-remaining") {
356        Some(inner) if inner < remaining => true,
357        // Tie on remaining: keep the inner headers only if their reset is at
358        // least as long as this phase's, so the longer-binding budget survives.
359        Some(inner) if inner == remaining => {
360            header_u64("ratelimit-reset").unwrap_or(0) >= secs_ceil(verdict.reset_after)
361        }
362        _ => false,
363    };
364    if !keep_inner {
365        attach_rate_headers(headers, limit, remaining, verdict);
366        // On an error response the rejecting layer already set `Retry-After`. We
367        // just replaced the budget with a longer-binding one, so widen
368        // `Retry-After` to that reset too; otherwise the client retries after the
369        // overwritten (shorter) wait and immediately hits this binding budget.
370        // Only ever widen: a 200 has no `Retry-After` to touch, and an already
371        // longer wait is left intact.
372        if let Some(current) = headers
373            .get("retry-after")
374            .and_then(|v| v.to_str().ok())
375            .and_then(|v| v.parse::<u64>().ok())
376        {
377            let reset = secs_ceil(verdict.reset_after);
378            if reset > current {
379                if let Ok(v) = reset.to_string().parse() {
380                    headers.insert("retry-after", v);
381                }
382            }
383        }
384    }
385}
386
387/// A `429` for a request rejected by the fleet-wide gate. The sliding-window
388/// estimate decays continuously (it does not free capacity at the epoch
389/// boundary), so `Retry-After` is a modest poll interval rather than the time to
390/// the boundary, which a client could wait out and still be rejected.
391#[cfg(feature = "redis")]
392fn global_reject(limit: u64, window: Duration) -> Response {
393    let backoff = fleet_backoff(window);
394    let verdict = Verdict {
395        allowed: false,
396        new_tat: Duration::ZERO,
397        remaining: 0,
398        retry_after: backoff,
399        reset_after: backoff,
400    };
401    too_many_requests(limit, 0, &verdict)
402}
403
404/// The keys a matched rule derives for one request.
405struct RuleKey {
406    /// De-identified key for the local store and shared counter: the rule's
407    /// stable fingerprint, the source tag, and a hash of the value. Raw client
408    /// values (API keys, principals, IPs) never reach the shared store or its
409    /// logs. Deterministic across instances so reconciliation keys agree.
410    store: String,
411    /// The raw identity for per-principal limit-service resolution (the service
412    /// must see the real principal to resolve its tier). Not persisted.
413    identity: String,
414}
415
416/// Derive the store key and resolution identity for a matched rule. Every source
417/// falls back to the client IP when its value is absent, so a limit can't be
418/// dodged by omitting a header or authenticating anonymously (subject to the
419/// rule's phase: a `jwt_claim` rule only runs post-auth).
420fn rule_key(
421    fingerprint: &str,
422    key: &KeySource,
423    client: &str,
424    headers: &HeaderMap,
425    claims: Option<&serde_json::Value>,
426) -> RuleKey {
427    let (tag, identity) = match key {
428        KeySource::Ip => ("ip", client.to_string()),
429        KeySource::Header(name) => match header_str(headers, name) {
430            Some(v) => ("hdr", v),
431            None => ("ip", client.to_string()),
432        },
433        KeySource::JwtClaim(claim) => match claims.and_then(|c| resolve::claim_str(c, claim)) {
434            Some(v) => ("jwt", v),
435            None => ("ip", client.to_string()),
436        },
437    };
438    RuleKey {
439        store: format!("{fingerprint}:{tag}:{}", matcher::short_hash(&identity)),
440        identity,
441    }
442}
443
444/// Parse a trusted-proxy entry as a CIDR range, accepting a bare IP as a /32
445/// or /128 host range.
446fn parse_cidr(s: &str) -> Result<ipnet::IpNet, String> {
447    if let Ok(net) = s.parse::<ipnet::IpNet>() {
448        return Ok(net);
449    }
450    if let Ok(ip) = s.parse::<std::net::IpAddr>() {
451        let prefix = if ip.is_ipv4() { 32 } else { 128 };
452        return ipnet::IpNet::new(ip, prefix)
453            .map_err(|e| format!("invalid trusted_proxies entry {s:?}: {e}"));
454    }
455    Err(format!("invalid trusted_proxies CIDR/IP: {s:?}"))
456}
457
458/// Resolve the client identity for keying.
459///
460/// `X-Forwarded-For` is trusted only when the direct `peer` is a configured
461/// trusted proxy, and even then the *rightmost* hop outside the trusted ranges
462/// is used: appending load balancers (nginx, ALB, GCP) add the connecting IP on
463/// the right, so the leftmost entries are attacker-controlled. Without connection
464/// info (a server not wired with `ConnectInfo`) we fail closed to a single
465/// `"unknown"` bucket rather than trusting client-supplied forwarding headers,
466/// which an attacker could otherwise rotate to dodge the limit.
467fn client_ip(
468    peer: Option<std::net::IpAddr>,
469    headers: &HeaderMap,
470    trusted: &[ipnet::IpNet],
471) -> String {
472    match peer {
473        Some(ip) => {
474            if trusted.iter().any(|net| net.contains(&ip)) {
475                if let Some(client) = rightmost_untrusted(headers, trusted) {
476                    return client;
477                }
478            }
479            ip.to_string()
480        }
481        None => "unknown".to_string(),
482    }
483}
484
485/// Rightmost `X-Forwarded-For` hop that is not within a trusted range, i.e. the
486/// last address appended by an untrusted party. Falls back to `X-Real-IP`.
487fn rightmost_untrusted(headers: &HeaderMap, trusted: &[ipnet::IpNet]) -> Option<String> {
488    if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
489        for hop in xff.split(',').rev() {
490            let hop = hop.trim();
491            if hop.is_empty() {
492                continue;
493            }
494            let trusted_hop = hop
495                .parse::<std::net::IpAddr>()
496                .is_ok_and(|ip| trusted.iter().any(|net| net.contains(&ip)));
497            if !trusted_hop {
498                return Some(hop.to_string());
499            }
500        }
501    }
502    // X-Real-IP is set by the proxy to the single real client address.
503    header_str(headers, "x-real-ip")
504}
505
506/// Trimmed, non-empty value of a header.
507fn header_str(headers: &HeaderMap, name: &str) -> Option<String> {
508    headers
509        .get(name)
510        .and_then(|v| v.to_str().ok())
511        .map(str::trim)
512        .filter(|s| !s.is_empty())
513        .map(str::to_string)
514}
515
516/// Attach the draft-ietf `RateLimit-*` headers describing the remaining budget.
517/// `remaining` is passed explicitly (rather than read from the verdict) so the
518/// caller can report the tighter of the local and fleet budgets.
519fn attach_rate_headers(headers: &mut HeaderMap, limit: u64, remaining: u64, verdict: &Verdict) {
520    if let Ok(v) = limit.to_string().parse() {
521        headers.insert("ratelimit-limit", v);
522    }
523    if let Ok(v) = remaining.to_string().parse() {
524        headers.insert("ratelimit-remaining", v);
525    }
526    if let Ok(v) = secs_ceil(verdict.reset_after).to_string().parse() {
527        headers.insert("ratelimit-reset", v);
528    }
529}
530
531/// A `429` response carrying the rate-limit headers plus `Retry-After`.
532fn too_many_requests(limit: u64, remaining: u64, verdict: &Verdict) -> Response {
533    let mut response = (
534        StatusCode::TOO_MANY_REQUESTS,
535        Json(serde_json::json!({
536            "error": "RESOURCE_EXHAUSTED",
537            "message": "rate limit exceeded",
538        })),
539    )
540        .into_response();
541    let headers = response.headers_mut();
542    attach_rate_headers(headers, limit, remaining, verdict);
543    if let Ok(v) = secs_ceil(verdict.retry_after).to_string().parse() {
544        headers.insert("retry-after", v);
545    }
546    response
547}
548
549/// Whole seconds, rounded up, for `Retry-After` / `RateLimit-Reset` (never report
550/// `0` for a non-zero wait).
551fn secs_ceil(d: Duration) -> u64 {
552    // Round up from nanoseconds, not truncated millis: a sub-millisecond wait
553    // must still report at least one second, never zero.
554    let nanos = d.as_nanos();
555    u64::try_from(nanos.div_ceil(1_000_000_000)).unwrap_or(u64::MAX)
556}
557
558#[cfg(test)]
559mod tests;