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;