use axum::{
body::Body,
extract::{Request, State},
http::{HeaderMap, Method, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
};
use lru::LruCache;
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use std::num::NonZeroUsize;
use std::time::{Duration, Instant};
static MAX_REQUESTS: Lazy<u32> = Lazy::new(|| {
std::env::var("SOLIDB_API_RATE_LIMIT")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(0)
});
static MAX_REQUESTS_PER_IP: Lazy<u32> = Lazy::new(|| {
std::env::var("SOLIDB_API_RATE_LIMIT_PER_IP")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or_else(|| MAX_REQUESTS.saturating_mul(IP_BACKSTOP_FACTOR))
});
const IP_BACKSTOP_FACTOR: u32 = 10;
static WINDOW_SECS: Lazy<u64> = Lazy::new(|| {
std::env::var("SOLIDB_API_RATE_WINDOW_SECS")
.ok()
.and_then(|value| value.parse().ok())
.filter(|secs| *secs > 0)
.unwrap_or(60)
});
const RATE_LIMITER_CAPACITY: usize = 50_000;
#[derive(Clone, Copy)]
struct Window {
start: Instant,
current: u32,
previous: u32,
}
impl Window {
fn new(now: Instant) -> Self {
Self {
start: now,
current: 0,
previous: 0,
}
}
fn roll(&mut self, now: Instant, window: Duration) {
let elapsed = now.duration_since(self.start);
if elapsed < window {
return;
}
if elapsed < window * 2 {
self.previous = self.current;
self.start += window;
} else {
self.previous = 0;
self.start = now;
}
self.current = 0;
}
fn estimate(&self, now: Instant, window: Duration) -> f64 {
let elapsed = now.duration_since(self.start).as_secs_f64();
let overlap = (1.0 - elapsed / window.as_secs_f64()).clamp(0.0, 1.0);
self.previous as f64 * overlap + self.current as f64
}
}
static API_RATE_LIMITER: Lazy<Mutex<LruCache<String, Window>>> = Lazy::new(|| {
Mutex::new(LruCache::new(
NonZeroUsize::new(RATE_LIMITER_CAPACITY).unwrap(),
))
});
fn client_ip(peer: Option<std::net::IpAddr>, headers: &HeaderMap) -> Option<String> {
let socket_ip = peer.map(|ip| ip.to_string());
if crate::server::auth::trust_proxy_headers() {
headers
.get("X-Forwarded-For")
.and_then(|h| h.to_str().ok())
.and_then(|s| s.split(',').next())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.or_else(|| {
headers
.get("X-Real-IP")
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string())
.filter(|s| !s.is_empty())
})
.or(socket_ip)
} else {
socket_ip
}
}
fn credential_key(headers: &HeaderMap) -> Option<String> {
use sha2::{Digest, Sha256};
let raw = headers
.get("X-API-Key")
.and_then(|h| h.to_str().ok())
.or_else(|| {
headers
.get("Authorization")
.and_then(|h| h.to_str().ok())
.and_then(|h| {
h.strip_prefix("Bearer ")
.or_else(|| h.strip_prefix("ApiKey "))
})
})
.map(str::trim)
.filter(|s| !s.is_empty())?;
let digest = Sha256::digest(raw.as_bytes());
Some(format!("cred:{}", hex::encode(&digest[..16])))
}
fn check_and_record_against(max_requests: u32, client: &str) -> Option<u64> {
if max_requests == 0 {
return None;
}
let now = Instant::now();
let window = Duration::from_secs(*WINDOW_SECS);
let mut limiter = API_RATE_LIMITER.lock();
if let Some(entry) = limiter.get_mut(client) {
entry.roll(now, window);
if entry.estimate(now, window) >= max_requests as f64 {
let remaining = window.saturating_sub(now.duration_since(entry.start));
return Some(remaining.as_secs() + 1);
}
entry.current += 1;
return None;
}
let mut fresh = Window::new(now);
fresh.current = 1;
limiter.put(client.to_string(), fresh);
None
}
fn is_internal_cluster_request(
state: &crate::server::handlers::AppState,
headers: &HeaderMap,
) -> bool {
let provided = match headers
.get("X-Cluster-Secret")
.and_then(|h| h.to_str().ok())
{
Some(value) if !value.is_empty() => value,
_ => return false,
};
let configured = state
.storage
.cluster_config()
.and_then(|c| c.keyfile.clone())
.unwrap_or_default();
if configured.is_empty() {
return false;
}
crate::server::auth::constant_time_eq(configured.as_bytes(), provided.as_bytes())
}
pub async fn api_rate_limit_middleware(
State(state): State<crate::server::handlers::AppState>,
peer: Result<
axum::extract::ConnectInfo<std::net::SocketAddr>,
axum::extract::rejection::ExtensionRejection,
>,
request: Request<Body>,
next: Next,
) -> Response {
if request.method() == Method::OPTIONS || is_internal_cluster_request(&state, request.headers())
{
return next.run(request).await;
}
if *MAX_REQUESTS == 0 {
return next.run(request).await;
}
let ip = client_ip(
peer.ok().map(|axum::extract::ConnectInfo(addr)| addr.ip()),
request.headers(),
);
let credential = credential_key(request.headers());
let outcome = match &credential {
Some(key) => check_and_record_against(*MAX_REQUESTS, key).or_else(|| {
ip.as_ref()
.and_then(|ip| check_and_record_against(*MAX_REQUESTS_PER_IP, ip))
}),
None => match &ip {
Some(ip) => check_and_record_against(*MAX_REQUESTS, ip),
None => None,
},
};
if let Some(retry_after) = outcome {
let client = credential.as_deref().or(ip.as_deref()).unwrap_or("unknown");
tracing::warn!(client = %client, path = %request.uri().path(), "API rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
[("Retry-After", retry_after.to_string())],
"Too Many Requests",
)
.into_response();
}
next.run(request).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_address_two_credentials_get_two_buckets() {
let mut app_a = HeaderMap::new();
app_a.insert("X-API-Key", "key-for-app-a".parse().unwrap());
let mut app_b = HeaderMap::new();
app_b.insert("X-API-Key", "key-for-app-b".parse().unwrap());
let a = credential_key(&app_a).expect("a credential is present");
let b = credential_key(&app_b).expect("a credential is present");
assert_ne!(a, b);
assert!(check_and_record_against(1, &a).is_none());
assert!(check_and_record_against(1, &b).is_none());
assert!(check_and_record_against(1, &a).is_some());
}
#[test]
fn the_same_credential_is_always_the_same_bucket() {
let mut headers = HeaderMap::new();
headers.insert("X-API-Key", "stable-key".parse().unwrap());
assert_eq!(credential_key(&headers), credential_key(&headers));
}
#[test]
fn the_credential_never_appears_in_the_bucket_key() {
let secret = "super-secret-api-key";
let mut headers = HeaderMap::new();
headers.insert("X-API-Key", secret.parse().unwrap());
let key = credential_key(&headers).unwrap();
assert!(!key.contains(secret));
assert!(key.starts_with("cred:"));
}
#[test]
fn both_credential_forms_are_recognised() {
for (name, value) in [
("X-API-Key", "k"),
("Authorization", "Bearer k"),
("Authorization", "ApiKey k"),
] {
let mut h = HeaderMap::new();
h.insert(name, value.parse().unwrap());
assert!(credential_key(&h).is_some(), "{name}: {value}");
}
assert!(credential_key(&HeaderMap::new()).is_none());
let mut blank = HeaderMap::new();
blank.insert("X-API-Key", "".parse().unwrap());
assert!(credential_key(&blank).is_none());
}
#[test]
fn zero_disables_the_limiter() {
let client = format!("test-zero-{}", std::process::id());
for _ in 0..5_000 {
assert!(check_and_record_against(0, &client).is_none());
}
}
#[test]
fn a_budget_of_zero_is_distinct_from_a_budget_of_one() {
let client = format!("test-one-{}", std::process::id());
assert!(check_and_record_against(1, &client).is_none());
assert!(check_and_record_against(1, &client).is_some());
}
#[test]
fn allows_requests_under_the_budget() {
let client = format!("test-under-{}", std::process::id());
for _ in 0..10 {
assert!(check_and_record_against(20, &client).is_none());
}
}
#[test]
fn rejects_requests_over_a_tiny_budget() {
let client = format!("test-over-{}", std::process::id());
let now = Instant::now();
let mut exhausted = Window::new(now);
exhausted.current = 5;
API_RATE_LIMITER.lock().put(client.clone(), exhausted);
let retry = check_and_record_against(5, &client);
assert!(retry.is_some());
assert!(retry.unwrap() >= 1);
}
#[test]
fn distinct_clients_have_distinct_buckets() {
let a = format!("test-a-{}", std::process::id());
let b = format!("test-b-{}", std::process::id());
assert!(check_and_record_against(1, &a).is_none());
assert!(check_and_record_against(1, &b).is_none());
assert!(check_and_record_against(1, &a).is_some());
}
#[test]
fn unidentifiable_clients_are_not_throttled() {
assert!(client_ip(None, &HeaderMap::new()).is_none());
}
#[test]
fn window_carries_the_previous_count_then_forgets_it() {
let window = Duration::from_secs(60);
let start = Instant::now();
let mut w = Window::new(start);
w.current = 100;
let next = start + window;
w.roll(next, window);
assert_eq!(w.previous, 100);
assert_eq!(w.current, 0);
assert!((w.estimate(next, window) - 100.0).abs() < 1.0);
let mid = next + window / 2;
assert!((w.estimate(mid, window) - 50.0).abs() < 1.0);
let later = next + window * 3;
w.roll(later, window);
assert_eq!(w.previous, 0);
assert_eq!(w.estimate(later, window), 0.0);
}
}