feat: complete Epic 1 production foundation
This commit is contained in:
@@ -10,20 +10,23 @@ use crank_runtime::{RateLimitCheckError, RateLimitRejection};
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ClientIdentityBucket(pub String);
|
||||
|
||||
pub async fn apply_api_rate_limit(
|
||||
State(state): State<AppState>,
|
||||
request: Request,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, ApiError> {
|
||||
let peer_ip = request
|
||||
.extensions()
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
.map(|ConnectInfo(address)| address.ip());
|
||||
let key = rate_limit_key(
|
||||
let key = client_rate_limit_key(
|
||||
request.headers(),
|
||||
request.uri().path(),
|
||||
peer_ip,
|
||||
state.trust_forwarded_headers,
|
||||
&state.trusted_proxy_ips,
|
||||
);
|
||||
if let Err(error) = state.api_rate_limiter.check(&key).await {
|
||||
return match error {
|
||||
@@ -37,6 +40,8 @@ pub async fn apply_api_rate_limit(
|
||||
};
|
||||
}
|
||||
|
||||
request.extensions_mut().insert(ClientIdentityBucket(key));
|
||||
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
@@ -46,13 +51,15 @@ fn rejection_context(rejection: RateLimitRejection) -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn rate_limit_key(
|
||||
pub fn client_rate_limit_key(
|
||||
headers: &HeaderMap,
|
||||
path: &str,
|
||||
peer_ip: Option<IpAddr>,
|
||||
trust_forwarded_headers: bool,
|
||||
trusted_proxy_ips: &[IpAddr],
|
||||
) -> String {
|
||||
if trust_forwarded_headers && let Some(client_ip) = forwarded_client_ip(headers) {
|
||||
if peer_ip.is_some_and(|peer_ip| trusted_proxy_ips.contains(&peer_ip))
|
||||
&& let Some(client_ip) = forwarded_client_ip(headers)
|
||||
{
|
||||
return format!("ip:{client_ip}");
|
||||
}
|
||||
|
||||
@@ -63,7 +70,8 @@ fn rate_limit_key(
|
||||
format!("anonymous:{path}")
|
||||
}
|
||||
|
||||
/// Resolves the client IP from proxy headers, assuming a single trusted proxy.
|
||||
/// Resolves the client IP from proxy headers after the immediate peer was
|
||||
/// matched against the trusted-proxy allowlist.
|
||||
///
|
||||
/// `X-Real-IP` is preferred because a trusted proxy (e.g. nginx) sets it to the
|
||||
/// real peer address. For `X-Forwarded-For` the proxy *appends* the observed
|
||||
@@ -96,7 +104,7 @@ mod tests {
|
||||
|
||||
use axum::http::{HeaderMap, HeaderValue, header::COOKIE};
|
||||
|
||||
use super::rate_limit_key;
|
||||
use super::client_rate_limit_key;
|
||||
|
||||
fn peer() -> Option<IpAddr> {
|
||||
Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)))
|
||||
@@ -112,7 +120,7 @@ mod tests {
|
||||
headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.5"));
|
||||
|
||||
assert_eq!(
|
||||
rate_limit_key(&headers, "/api/auth/login", peer(), true),
|
||||
client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]),
|
||||
"ip:10.0.0.5"
|
||||
);
|
||||
}
|
||||
@@ -124,7 +132,23 @@ mod tests {
|
||||
headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.9"));
|
||||
|
||||
assert_eq!(
|
||||
rate_limit_key(&headers, "/api/auth/login", peer(), false),
|
||||
client_rate_limit_key(&headers, "/api/auth/login", peer(), &[]),
|
||||
"ip:203.0.113.7"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_forwarded_headers_from_unlisted_peer() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.9"));
|
||||
|
||||
assert_eq!(
|
||||
client_rate_limit_key(
|
||||
&headers,
|
||||
"/api/auth/login",
|
||||
peer(),
|
||||
&[IpAddr::V4(Ipv4Addr::new(198, 51, 100, 10))]
|
||||
),
|
||||
"ip:203.0.113.7"
|
||||
);
|
||||
}
|
||||
@@ -139,7 +163,7 @@ mod tests {
|
||||
headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.9"));
|
||||
|
||||
assert_eq!(
|
||||
rate_limit_key(&headers, "/api/auth/login", peer(), true),
|
||||
client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]),
|
||||
"ip:10.0.0.9"
|
||||
);
|
||||
}
|
||||
@@ -155,7 +179,7 @@ mod tests {
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
rate_limit_key(&headers, "/api/auth/login", peer(), true),
|
||||
client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]),
|
||||
"ip:10.0.0.6"
|
||||
);
|
||||
}
|
||||
@@ -165,7 +189,7 @@ mod tests {
|
||||
let headers = HeaderMap::new();
|
||||
|
||||
assert_eq!(
|
||||
rate_limit_key(&headers, "/api/auth/login", peer(), true),
|
||||
client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]),
|
||||
"ip:203.0.113.7"
|
||||
);
|
||||
}
|
||||
@@ -180,7 +204,7 @@ mod tests {
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
rate_limit_key(&headers, "/api/auth/login", peer(), true),
|
||||
client_rate_limit_key(&headers, "/api/auth/login", peer(), &[peer().unwrap()]),
|
||||
"ip:203.0.113.7"
|
||||
);
|
||||
}
|
||||
@@ -190,7 +214,7 @@ mod tests {
|
||||
let headers = HeaderMap::new();
|
||||
|
||||
assert_eq!(
|
||||
rate_limit_key(&headers, "/api/auth/login", None, false),
|
||||
client_rate_limit_key(&headers, "/api/auth/login", None, &[]),
|
||||
"anonymous:/api/auth/login"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user