0e8f1ca03a
Добавить структурированные журналы, метрики, трассировку и безопасный канал критических ошибок. Усилить границы рантайма, тесты, проверку зависимостей и сценарии развёртывания.
198 lines
5.6 KiB
Rust
198 lines
5.6 KiB
Rust
use std::net::{IpAddr, SocketAddr};
|
|
|
|
use axum::{
|
|
extract::{ConnectInfo, Request, State},
|
|
http::HeaderMap,
|
|
middleware::Next,
|
|
response::Response,
|
|
};
|
|
use crank_runtime::{RateLimitCheckError, RateLimitRejection};
|
|
|
|
use crate::{error::ApiError, state::AppState};
|
|
|
|
pub async fn apply_api_rate_limit(
|
|
State(state): State<AppState>,
|
|
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(
|
|
request.headers(),
|
|
request.uri().path(),
|
|
peer_ip,
|
|
state.trust_forwarded_headers,
|
|
);
|
|
if let Err(error) = state.api_rate_limiter.check(&key).await {
|
|
return match error {
|
|
RateLimitCheckError::Rejected(rejection) => Err(ApiError::rate_limited_with_context(
|
|
"request rate limit exceeded",
|
|
rejection_context(rejection),
|
|
)),
|
|
RateLimitCheckError::StoreUnavailable => {
|
|
Err(ApiError::internal("rate limit service unavailable"))
|
|
}
|
|
};
|
|
}
|
|
|
|
Ok(next.run(request).await)
|
|
}
|
|
|
|
fn rejection_context(rejection: RateLimitRejection) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"retry_after_ms": rejection.retry_after_ms,
|
|
})
|
|
}
|
|
|
|
fn rate_limit_key(
|
|
headers: &HeaderMap,
|
|
path: &str,
|
|
peer_ip: Option<IpAddr>,
|
|
trust_forwarded_headers: bool,
|
|
) -> String {
|
|
if trust_forwarded_headers && let Some(client_ip) = forwarded_client_ip(headers) {
|
|
return format!("ip:{client_ip}");
|
|
}
|
|
|
|
if let Some(peer_ip) = peer_ip {
|
|
return format!("ip:{peer_ip}");
|
|
}
|
|
|
|
format!("anonymous:{path}")
|
|
}
|
|
|
|
/// Resolves the client IP from proxy headers, assuming a single trusted proxy.
|
|
///
|
|
/// `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
|
|
/// peer, so the last entry is the trustworthy hop; taking the first entry (as
|
|
/// naive implementations do) would let a client spoof its address by sending a
|
|
/// pre-populated header.
|
|
fn forwarded_client_ip(headers: &HeaderMap) -> Option<IpAddr> {
|
|
if let Some(real_ip) = header_value(headers, "x-real-ip").and_then(parse_ip) {
|
|
return Some(real_ip);
|
|
}
|
|
|
|
header_value(headers, "x-forwarded-for")?
|
|
.split(',')
|
|
.map(str::trim)
|
|
.rfind(|value| !value.is_empty())
|
|
.and_then(parse_ip)
|
|
}
|
|
|
|
fn header_value<'a>(headers: &'a HeaderMap, name: &'static str) -> Option<&'a str> {
|
|
headers.get(name)?.to_str().ok().map(str::trim)
|
|
}
|
|
|
|
fn parse_ip(value: &str) -> Option<IpAddr> {
|
|
value.parse().ok()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::net::{IpAddr, Ipv4Addr};
|
|
|
|
use axum::http::{HeaderMap, HeaderValue, header::COOKIE};
|
|
|
|
use super::rate_limit_key;
|
|
|
|
fn peer() -> Option<IpAddr> {
|
|
Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)))
|
|
}
|
|
|
|
#[test]
|
|
fn unverified_session_cookie_cannot_change_client_key() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
COOKIE,
|
|
HeaderValue::from_static("theme=dark; crank_session=sess_123.secret_456"),
|
|
);
|
|
headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.5"));
|
|
|
|
assert_eq!(
|
|
rate_limit_key(&headers, "/api/auth/login", peer(), true),
|
|
"ip:10.0.0.5"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ignores_forwarded_headers_when_untrusted() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.5"));
|
|
headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.9"));
|
|
|
|
assert_eq!(
|
|
rate_limit_key(&headers, "/api/auth/login", peer(), false),
|
|
"ip:203.0.113.7"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn prefers_real_ip_when_trusted() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
"x-forwarded-for",
|
|
HeaderValue::from_static("1.2.3.4, 10.0.0.6"),
|
|
);
|
|
headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.9"));
|
|
|
|
assert_eq!(
|
|
rate_limit_key(&headers, "/api/auth/login", peer(), true),
|
|
"ip:10.0.0.9"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn uses_last_forwarded_hop_when_trusted() {
|
|
// A client can prepend spoofed entries; the trusted proxy appends the
|
|
// real peer, so the last entry is authoritative.
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
"x-forwarded-for",
|
|
HeaderValue::from_static("1.2.3.4, 10.0.0.6"),
|
|
);
|
|
|
|
assert_eq!(
|
|
rate_limit_key(&headers, "/api/auth/login", peer(), true),
|
|
"ip:10.0.0.6"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn falls_back_to_peer_ip_without_headers() {
|
|
let headers = HeaderMap::new();
|
|
|
|
assert_eq!(
|
|
rate_limit_key(&headers, "/api/auth/login", peer(), true),
|
|
"ip:203.0.113.7"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ignores_invalid_forwarded_ip_values() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert("x-real-ip", HeaderValue::from_static("not-an-ip"));
|
|
headers.insert(
|
|
"x-forwarded-for",
|
|
HeaderValue::from_static("198.51.100.8, also-not-an-ip"),
|
|
);
|
|
|
|
assert_eq!(
|
|
rate_limit_key(&headers, "/api/auth/login", peer(), true),
|
|
"ip:203.0.113.7"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn falls_back_to_path_without_peer() {
|
|
let headers = HeaderMap::new();
|
|
|
|
assert_eq!(
|
|
rate_limit_key(&headers, "/api/auth/login", None, false),
|
|
"anonymous:/api/auth/login"
|
|
);
|
|
}
|
|
}
|