pub mod gcra;
#[cfg(feature = "redis")]
pub mod global;
pub mod matcher;
pub mod rate;
pub mod resolve;
pub mod store;
pub mod window;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use axum::extract::Request;
use axum::http::header::RETRY_AFTER;
use axum::http::{HeaderMap, HeaderName, HeaderValue};
use axum::middleware::Next;
use axum::response::Response;
use crate::client_address::{ClientAddress, Resolution};
use crate::config::ShieldConfig;
use gcra::Verdict;
use matcher::{CompiledProfile, CompiledRule, KeySource, Phase};
use store::GcraStore;
pub struct Shield {
rules: Vec<CompiledRule>,
profiles: HashMap<String, CompiledProfile>,
default_profile: Option<CompiledProfile>,
jwt_limits: Option<resolve::JwtLimits>,
limit_service: Option<Arc<resolve::LimitService>>,
#[cfg(feature = "redis")]
global: Option<Arc<global::GlobalCounters>>,
store: GcraStore,
}
impl Shield {
pub fn build(config: &ShieldConfig) -> Result<Option<Arc<Self>>, String> {
if !config.enabled {
return Ok(None);
}
if config.rules.is_empty() {
return Err(
"shield.enabled is true but no rules are configured (note the schema: \
profiles + rules + sync, not the older endpoint_classes/identifier_endpoints)"
.to_string(),
);
}
let profiles = matcher::compile_profiles(&config.profiles)?;
let rules = matcher::compile_rules(&config.rules, &profiles)?;
let default_profile =
match &config.default_profile {
Some(name) => Some(*profiles.get(name).ok_or_else(|| {
format!("default_profile references unknown profile {name:?}")
})?),
None => None,
};
let jwt_limits = config
.jwt_limits
.as_ref()
.map(resolve::JwtLimits::from_config);
let limit_service = match &config.limit_service {
Some(cfg) => Some(resolve::LimitService::build(cfg, profiles.clone())?),
None => None,
};
#[cfg(feature = "redis")]
let global = match &config.sync {
Some(sync) => {
let g = global::GlobalCounters::build(
&sync.redis_url,
Duration::from_millis(sync.interval_ms),
)?;
g.spawn().then_some(g)
}
None => None,
};
#[cfg(not(feature = "redis"))]
if config.sync.is_some() {
tracing::warn!(
"shield.sync is set but the `redis` feature is not compiled in; \
staying local-only (per-instance limits)"
);
}
Ok(Some(Arc::new(Self {
rules,
profiles,
default_profile,
jwt_limits,
limit_service,
#[cfg(feature = "redis")]
global,
store: GcraStore::new(),
})))
}
fn match_rule(&self, path: &str, phase: Phase) -> Option<&CompiledRule> {
self.rules
.iter()
.find(|r| r.phase == phase && r.matcher.is_match(path))
}
pub(crate) fn enforces(&self, phase: Phase) -> bool {
self.rules.iter().any(|rule| rule.phase == phase)
}
fn resolve_limit(
&self,
rule: &CompiledRule,
claims: Option<&serde_json::Value>,
identity: &str,
) -> Option<CompiledProfile> {
if let (Some(jwt), Some(claims)) = (&self.jwt_limits, claims) {
if let Some(profile) = jwt.resolve(claims, &self.profiles) {
return Some(profile);
}
}
if let Some(service) = &self.limit_service {
if let Some(profile) = service.resolve(identity) {
return Some(profile);
}
}
self.static_profile(rule)
}
fn static_profile(&self, rule: &CompiledRule) -> Option<CompiledProfile> {
rule.profile
.as_ref()
.and_then(|name| self.profiles.get(name))
.or(self.default_profile.as_ref())
.copied()
}
}
pub async fn pre_auth_middleware(
axum::extract::State(shield): axum::extract::State<Arc<Shield>>,
request: Request,
next: Next,
) -> Response {
enforce(&shield, Phase::PreAuth, request, next).await
}
pub async fn post_auth_middleware(
axum::extract::State(shield): axum::extract::State<Arc<Shield>>,
request: Request,
next: Next,
) -> Response {
enforce(&shield, Phase::PostAuth, request, next).await
}
async fn enforce(shield: &Shield, phase: Phase, request: Request, next: Next) -> Response {
match shield.decide(phase, &request) {
Decision::Pass => next.run(request).await,
Decision::Reject(response) => response,
Decision::Report(report) => {
let mut response = next.run(request).await;
report.apply(&mut response);
response
}
}
}
pub(crate) enum Decision {
Pass,
Reject(Response),
Report(Report),
}
pub(crate) struct Report {
limit: u64,
remaining: u64,
verdict: Verdict,
}
impl Report {
pub(crate) fn apply(&self, response: &mut Response) {
maybe_tighten_rate_headers(
response.headers_mut(),
self.limit,
self.remaining,
&self.verdict,
);
}
}
impl Shield {
pub(crate) fn decide<B>(&self, phase: Phase, request: &http::Request<B>) -> Decision {
let path = request.uri().path();
let Some(rule) = self.match_rule(path, phase) else {
return Decision::Pass;
};
self.charge(rule, request)
}
fn charge<B>(&self, rule: &CompiledRule, request: &http::Request<B>) -> Decision {
let unresolved;
let client = match request.extensions().get::<ClientAddress>() {
Some(client) => client,
None => {
let peer = request
.extensions()
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
.map(|ci| ci.0);
unresolved = ClientAddress::from_peer(peer);
&unresolved
}
};
let claims = request
.extensions()
.get::<crate::auth::ValidatedClaims>()
.map(|c| c.0.as_ref());
let key = rule_key(
&rule.fingerprint,
&rule.key,
client,
request.headers(),
claims,
);
let Some(profile) = self.resolve_limit(rule, claims, key.identity.as_str()) else {
return Decision::Pass;
};
#[cfg(feature = "redis")]
let fleet_remaining = self
.global
.as_ref()
.map(|g| g.fleet_remaining(key.store.as_str(), profile.limit, profile.window));
#[cfg(feature = "redis")]
if fleet_remaining == Some(0) {
return Decision::Reject(global_reject(profile.limit, profile.window));
}
let verdict = self.store.check(key.store.as_str(), &profile.gcra);
if !verdict.allowed {
return Decision::Reject(too_many_requests(
profile.limit,
verdict.remaining,
&verdict,
));
}
#[cfg(not(feature = "redis"))]
let (reported, header_verdict) = reconciled_headers(verdict, None, profile.window);
#[cfg(feature = "redis")]
let (reported, header_verdict) = {
if fleet_remaining.is_some() {
if let Some(global) = &self.global {
global.record(key.store.as_str(), profile.window);
}
}
reconciled_headers(verdict, fleet_remaining, profile.window)
};
Decision::Report(Report {
limit: profile.limit,
remaining: reported,
verdict: header_verdict,
})
}
}
fn reconciled_headers(
verdict: Verdict,
fleet_remaining: Option<u64>,
window: Duration,
) -> (u64, Verdict) {
let mut reported = verdict.remaining;
let mut hv = verdict;
if let Some(fr) = fleet_remaining {
let fleet_r = fr.saturating_sub(1);
if fleet_r <= reported {
reported = fleet_r;
let backoff = fleet_backoff(window);
hv.reset_after = hv.reset_after.max(backoff);
hv.retry_after = hv.retry_after.max(backoff);
}
}
(reported, hv)
}
fn fleet_backoff(window: Duration) -> Duration {
(window / 10).max(Duration::from_secs(1))
}
fn maybe_tighten_rate_headers(
headers: &mut HeaderMap,
limit: u64,
remaining: u64,
verdict: &Verdict,
) {
let header_u64 = |name: &HeaderName| {
headers
.get(name)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
};
let keep_inner = match header_u64(&RATELIMIT_REMAINING) {
Some(inner) if inner < remaining => true,
Some(inner) if inner == remaining => {
header_u64(&RATELIMIT_RESET).unwrap_or(0) >= secs_ceil(verdict.reset_after)
}
_ => false,
};
if !keep_inner {
attach_rate_headers(headers, limit, remaining, verdict);
if let Some(current) = headers
.get(RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
{
let reset = secs_ceil(verdict.reset_after);
if reset > current {
headers.insert(RETRY_AFTER, HeaderValue::from(reset));
}
}
}
}
#[cfg(feature = "redis")]
fn global_reject(limit: u64, window: Duration) -> Response {
let backoff = fleet_backoff(window);
let verdict = Verdict {
allowed: false,
new_tat: Duration::ZERO,
remaining: 0,
retry_after: backoff,
reset_after: backoff,
};
too_many_requests(limit, 0, &verdict)
}
struct RuleKey<'r> {
store: StoreKey,
identity: Identity<'r>,
}
struct StoreKey {
bytes: [u8; 72],
len: usize,
}
impl StoreKey {
fn new(fingerprint: &str, tag: &str, hash: &[u8; 32]) -> Self {
let mut key = Self {
bytes: [0; 72],
len: 0,
};
for part in [fingerprint.as_bytes(), b":", tag.as_bytes(), b":", hash] {
key.bytes[key.len..key.len + part.len()].copy_from_slice(part);
key.len += part.len();
}
key
}
fn as_str(&self) -> &str {
std::str::from_utf8(&self.bytes[..self.len]).expect("hex digits, a tag and colons")
}
}
enum Identity<'r> {
Address(AddressText),
Borrowed(&'r str),
Owned(String),
}
impl Identity<'_> {
fn as_str(&self) -> &str {
match self {
Identity::Address(address) => address.as_str(),
Identity::Borrowed(text) => text,
Identity::Owned(text) => text,
}
}
}
struct AddressText {
bytes: [u8; 45],
len: usize,
}
impl AddressText {
fn new(ip: std::net::IpAddr) -> Self {
use std::fmt::Write;
let mut text = Self {
bytes: [0; 45],
len: 0,
};
write!(text, "{ip}").expect("an IP address fits in 45 characters");
text
}
fn as_str(&self) -> &str {
std::str::from_utf8(&self.bytes[..self.len]).expect("an address is written as text")
}
}
impl std::fmt::Write for AddressText {
fn write_str(&mut self, s: &str) -> std::fmt::Result {
let end = self.len + s.len();
let slot = self.bytes.get_mut(self.len..end).ok_or(std::fmt::Error)?;
slot.copy_from_slice(s.as_bytes());
self.len = end;
Ok(())
}
}
fn rule_key<'r>(
fingerprint: &str,
key: &KeySource,
client: &ClientAddress,
headers: &'r HeaderMap,
claims: Option<&serde_json::Value>,
) -> RuleKey<'r> {
let (tag, identity) = match key {
KeySource::Ip => ("ip", address_identity(client)),
KeySource::Header(name) => match header_str(headers, name) {
Some(v) => ("hdr", Identity::Borrowed(v)),
None => ("ip", address_identity(client)),
},
KeySource::JwtClaim(claim) => match claims.and_then(|c| resolve::claim_str(c, claim)) {
Some(v) => ("jwt", Identity::Owned(v)),
None => ("ip", address_identity(client)),
},
};
let hash = matcher::short_hash_hex(identity.as_str().as_bytes());
RuleKey {
store: StoreKey::new(fingerprint, tag, &hash),
identity,
}
}
fn address_identity(client: &ClientAddress) -> Identity<'static> {
match client.resolution() {
Resolution::Peer(ip) | Resolution::Forwarded(ip) => Identity::Address(AddressText::new(ip)),
Resolution::Invalid(_) => Identity::Borrowed("invalid"),
Resolution::Unavailable => Identity::Borrowed("unknown"),
}
}
fn header_str<'h>(headers: &'h HeaderMap, name: &str) -> Option<&'h str> {
headers
.get(name)
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|s| !s.is_empty())
}
static RATELIMIT_LIMIT: HeaderName = HeaderName::from_static("ratelimit-limit");
static RATELIMIT_REMAINING: HeaderName = HeaderName::from_static("ratelimit-remaining");
static RATELIMIT_RESET: HeaderName = HeaderName::from_static("ratelimit-reset");
fn attach_rate_headers(headers: &mut HeaderMap, limit: u64, remaining: u64, verdict: &Verdict) {
headers.insert(RATELIMIT_LIMIT.clone(), HeaderValue::from(limit));
headers.insert(RATELIMIT_REMAINING.clone(), HeaderValue::from(remaining));
headers.insert(
RATELIMIT_RESET.clone(),
HeaderValue::from(secs_ceil(verdict.reset_after)),
);
}
fn too_many_requests(limit: u64, remaining: u64, verdict: &Verdict) -> Response {
let mut response = crate::guard::reject(tonic::Code::ResourceExhausted, "rate limit exceeded");
let headers = response.headers_mut();
attach_rate_headers(headers, limit, remaining, verdict);
headers.insert(
RETRY_AFTER,
HeaderValue::from(secs_ceil(verdict.retry_after)),
);
response
}
fn secs_ceil(d: Duration) -> u64 {
let nanos = d.as_nanos();
u64::try_from(nanos.div_ceil(1_000_000_000)).unwrap_or(u64::MAX)
}
#[cfg(test)]
mod tests;