use std::{num::NonZeroU32, sync::Arc};
use axum::{
body::Body,
extract::{Request, State},
http::{HeaderMap, StatusCode, header},
middleware::Next,
response::{IntoResponse, Response},
};
use governor::{DefaultKeyedRateLimiter, Quota, RateLimiter, clock::Clock};
const SESSION_COOKIE: &str = "session";
const MAX_TRACKED_KEYS: usize = 50_000;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum KeyBy {
Global,
Session,
ClientIp,
}
pub struct Limit {
paths: &'static [&'static str],
key_by: KeyBy,
limiter: DefaultKeyedRateLimiter<String>,
}
impl Limit {
pub fn per_hour(paths: &'static [&'static str], key_by: KeyBy, count: u32) -> Self {
Self::new(paths, key_by, Quota::per_hour(non_zero(count)))
}
pub fn per_minute(paths: &'static [&'static str], key_by: KeyBy, count: u32) -> Self {
Self::new(paths, key_by, Quota::per_minute(non_zero(count)))
}
fn new(paths: &'static [&'static str], key_by: KeyBy, quota: Quota) -> Self {
Self {
paths,
key_by,
limiter: RateLimiter::keyed(quota),
}
}
}
fn non_zero(count: u32) -> NonZeroU32 {
NonZeroU32::new(count).expect("a rate limit must allow at least one request")
}
#[derive(Default)]
pub struct RateLimits {
limits: Vec<Limit>,
client_ip_header: Option<String>,
}
impl RateLimits {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn client_ip_header(mut self, name: impl Into<String>) -> Self {
self.client_ip_header = Some(name.into());
self
}
#[must_use]
pub fn client_ip_header_from_env(self, var: &str) -> Self {
match std::env::var(var)
.ok()
.map(|value| value.trim().to_string())
{
Some(name) if !name.is_empty() => self.client_ip_header(name),
_ => self,
}
}
#[must_use]
pub fn limit(mut self, limit: Limit) -> Self {
self.limits.push(limit);
self
}
pub fn shared(self) -> Arc<RateLimits> {
Arc::new(self)
}
pub fn apply<S>(self, router: axum::Router<S>) -> axum::Router<S>
where
S: Clone + Send + Sync + 'static,
{
router.layer(axum::middleware::from_fn_with_state(
Arc::new(self),
enforce,
))
}
fn key(&self, by: KeyBy, headers: &HeaderMap) -> String {
match by {
KeyBy::Global => "global".to_string(),
KeyBy::Session => format!("session:{}", session_key(headers)),
KeyBy::ClientIp => self
.client_ip_header
.as_deref()
.and_then(|name| headers.get(name))
.and_then(|value| value.to_str().ok())
.map(|ip| format!("ip:{}", ip.trim()))
.unwrap_or_else(|| format!("session:{}", session_key(headers))),
}
}
fn check(&self, path: &str, headers: &HeaderMap) -> Option<u64> {
let mut wait = None::<u64>;
for limit in self
.limits
.iter()
.filter(|limit| limit.paths.contains(&path))
{
let key = self.key(limit.key_by, headers);
let verdict = limit.limiter.check_key(&key);
if limit.limiter.len() > MAX_TRACKED_KEYS {
limit.limiter.retain_recent();
}
if let Err(not_until) = verdict {
let secs = not_until
.wait_time_from(limit.limiter.clock().now())
.as_secs()
.max(1);
wait = Some(wait.map_or(secs, |current| current.max(secs)));
}
}
wait
}
}
fn session_key(headers: &HeaderMap) -> String {
headers
.get(header::COOKIE)
.and_then(|value| value.to_str().ok())
.and_then(|raw| {
raw.split(';').find_map(|pair| {
let (name, value) = pair.trim().split_once('=')?;
(name == SESSION_COOKIE).then(|| value.to_string())
})
})
.unwrap_or_else(|| "no-session".to_string())
}
pub async fn enforce(
State(limits): State<Arc<RateLimits>>,
request: Request,
next: Next,
) -> Response {
match limits.check(request.uri().path(), request.headers()) {
None => next.run(request).await,
Some(retry_after) => too_many_requests(retry_after),
}
}
fn too_many_requests(retry_after_secs: u64) -> Response {
let body = serde_json::json!({
"message": "You're doing that too often. Please wait a moment and try again.",
"code": 429,
});
(
StatusCode::TOO_MANY_REQUESTS,
[
(header::CONTENT_TYPE, "application/json".to_string()),
(header::RETRY_AFTER, retry_after_secs.to_string()),
],
Body::from(body.to_string()),
)
.into_response()
}
#[cfg(test)]
mod tests {
use axum::{Router, http::HeaderValue, routing::post};
use tower::ServiceExt;
use super::*;
fn headers(pairs: &[(&'static str, &str)]) -> HeaderMap {
let mut map = HeaderMap::new();
for (name, value) in pairs {
map.insert(*name, HeaderValue::from_str(value).unwrap());
}
map
}
const SIGN_IN: &[&str] = &["/sign_in"];
#[test]
fn forged_cookies_cannot_reset_a_global_limit() {
let limits = RateLimits::new().limit(Limit::per_hour(SIGN_IN, KeyBy::Global, 2));
assert!(
limits
.check("/sign_in", &headers(&[("cookie", "session=one")]))
.is_none()
);
assert!(
limits
.check("/sign_in", &headers(&[("cookie", "session=two")]))
.is_none()
);
assert!(
limits
.check("/sign_in", &headers(&[("cookie", "session=three")]))
.is_some()
);
assert!(limits.check("/other", &HeaderMap::new()).is_none());
}
#[test]
fn a_session_is_keyed_by_its_cookie_and_a_missing_one_shares_a_bucket() {
let limits = RateLimits::new();
assert_eq!(
limits.key(KeyBy::Session, &headers(&[("cookie", "a=1; session=abc")])),
"session:abc"
);
assert_eq!(
limits.key(KeyBy::Session, &HeaderMap::new()),
"session:no-session"
);
}
#[test]
fn the_address_is_used_only_when_a_header_is_named_and_present() {
let named = RateLimits::new().client_ip_header("cf-connecting-ip");
let both = headers(&[
("cf-connecting-ip", " 203.0.113.7 "),
("cookie", "session=abc"),
]);
assert_eq!(named.key(KeyBy::ClientIp, &both), "ip:203.0.113.7");
assert_eq!(
named.key(KeyBy::ClientIp, &headers(&[("cookie", "session=abc")])),
"session:abc"
);
assert_eq!(RateLimits::new().key(KeyBy::ClientIp, &both), "session:abc");
}
#[test]
fn a_limit_lets_a_burst_through_then_refuses_with_a_wait() {
let limits = RateLimits::new().limit(Limit::per_hour(SIGN_IN, KeyBy::Session, 3));
let visitor = headers(&[("cookie", "session=abc")]);
for _ in 0..3 {
assert_eq!(limits.check("/sign_in", &visitor), None);
}
let wait = limits
.check("/sign_in", &visitor)
.expect("the fourth is refused");
assert!((1..=3600).contains(&wait), "{wait}");
assert_eq!(
limits.check("/sign_in", &headers(&[("cookie", "session=other")])),
None
);
assert_eq!(limits.check("/elsewhere", &visitor), None);
}
#[test]
fn changing_session_does_not_reset_an_address_limit() {
let limits = RateLimits::new()
.client_ip_header("x-client-ip")
.limit(Limit::per_hour(SIGN_IN, KeyBy::ClientIp, 2));
let from = |session: &str| headers(&[("x-client-ip", "198.51.100.1"), ("cookie", session)]);
assert_eq!(limits.check("/sign_in", &from("session=one")), None);
assert_eq!(limits.check("/sign_in", &from("session=two")), None);
assert!(limits.check("/sign_in", &from("session=three")).is_some());
}
#[tokio::test]
async fn the_layer_answers_429_in_the_shape_the_client_reads() {
let app = RateLimits::new()
.limit(Limit::per_hour(SIGN_IN, KeyBy::Session, 1))
.apply(Router::new().route("/sign_in", post(|| async { "ok" })));
let call = || {
Request::builder()
.method("POST")
.uri("/sign_in")
.header("cookie", "session=abc")
.body(Body::empty())
.unwrap()
};
assert_eq!(
app.clone().oneshot(call()).await.unwrap().status(),
StatusCode::OK
);
let refused = app.oneshot(call()).await.unwrap();
assert_eq!(refused.status(), StatusCode::TOO_MANY_REQUESTS);
assert!(refused.headers().contains_key(header::RETRY_AFTER));
let bytes = axum::body::to_bytes(refused.into_body(), 4096)
.await
.unwrap();
let payload: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(payload["code"], 429);
assert!(payload["message"].as_str().unwrap().contains("too often"));
}
}