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