yggdrasil/src/api/rate_limit.rs
xfy 00478e4a1a perf(rate-limit): use lenient bucket when client IP is unknown
TRUSTED_PROXY_COUNT=0(默认)时,Dioxus server function 拿不到 TCP 对端,
get_client_ip 返回 "unknown",所有匿名请求共用严格桶(1 req/s, burst 5),
正常用户的高频请求被误杀。check_strict_limit 现在对 unknown IP 改走
宽松桶(30 req/s, burst 100,可通过 RATE_LIMIT_UNKNOWN_* 调整)。
配好反向代理后走真实 IP,仍命中严格桶。新增 2 个 serial 测试锁定两路行为。
2026-06-18 11:28:06 +08:00

445 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 基于 governor 的多级限流。
//!
//! 提供 strict、upload、image、comment 四个限流器,
//! 支持从 `X-Forwarded-For` / `X-Real-IP` 中提取客户端 IP
//! 并可通过 `TRUSTED_PROXY_COUNT` 配置信任代理层数。
//!
//! 当未配置可信代理时Axum handler 可回退到 TCP 连接的对端地址;
//! Dioxus server function 无法获取对端地址,会退回到 `"unknown"` key
//! 此时所有请求共享一个限流桶。生产环境应在反向代理后部署并正确配置
//! `TRUSTED_PROXY_COUNT`。
//!
//! 仅在 `feature = "server"` 时生效。
#[cfg(feature = "server")]
use axum::http::StatusCode;
#[cfg(feature = "server")]
use governor::{DefaultKeyedRateLimiter, Quota, RateLimiter};
#[cfg(feature = "server")]
use std::num::NonZeroU32;
#[cfg(feature = "server")]
use std::sync::LazyLock;
#[cfg(feature = "server")]
fn env_or(key: &str, default: u32) -> NonZeroU32 {
let val = std::env::var(key)
.ok()
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(default);
NonZeroU32::new(val.max(1)).unwrap()
}
#[cfg(feature = "server")]
static STRICT_LIMITER: LazyLock<DefaultKeyedRateLimiter<String>> = LazyLock::new(|| {
RateLimiter::keyed(
Quota::per_second(env_or("RATE_LIMIT_STRICT_PER_SEC", 1))
.allow_burst(env_or("RATE_LIMIT_STRICT_BURST", 5)),
)
});
#[cfg(feature = "server")]
static UPLOAD_LIMITER: LazyLock<DefaultKeyedRateLimiter<String>> = LazyLock::new(|| {
RateLimiter::keyed(
Quota::per_second(env_or("RATE_LIMIT_UPLOAD_PER_SEC", 2))
.allow_burst(env_or("RATE_LIMIT_UPLOAD_BURST", 15)),
)
});
#[cfg(feature = "server")]
static IMAGE_LIMITER: LazyLock<DefaultKeyedRateLimiter<String>> = LazyLock::new(|| {
RateLimiter::keyed(
Quota::per_second(env_or("RATE_LIMIT_IMAGE_PER_SEC", 10))
.allow_burst(env_or("RATE_LIMIT_IMAGE_BURST", 50)),
)
});
#[cfg(feature = "server")]
static COMMENT_LIMITER: LazyLock<DefaultKeyedRateLimiter<String>> = LazyLock::new(|| {
RateLimiter::keyed(
Quota::per_second(env_or("RATE_LIMIT_COMMENT_PER_SEC", 1))
.allow_burst(env_or("RATE_LIMIT_COMMENT_BURST", 5)),
)
});
#[cfg(feature = "server")]
/// 当无法识别真实客户端 IP"unknown")时使用的宽松限流桶。
///
/// TRUSTED_PROXY_COUNT=0默认Dioxus server function 拿不到 TCP 对端地址,
/// get_client_ip 会返回 "unknown",导致所有匿名请求共享同一个严格桶
/// 1 req/s, burst 5正常用户的高频请求被误杀。此桶阈值更高
/// 通过 env RATE_LIMIT_UNKNOWN_PER_SEC / RATE_LIMIT_UNKNOWN_BURST 可调。
static UNKNOWN_BUCKET_LIMITER: LazyLock<DefaultKeyedRateLimiter<String>> = LazyLock::new(|| {
RateLimiter::keyed(
Quota::per_second(env_or("RATE_LIMIT_UNKNOWN_PER_SEC", 30))
.allow_burst(env_or("RATE_LIMIT_UNKNOWN_BURST", 100)),
)
});
#[cfg(feature = "server")]
/// 检查评论请求是否超出限流阈值。
pub fn check_comment_limit(ip: &str) -> Result<(), String> {
COMMENT_LIMITER
.check_key(&ip.to_string())
.map(|_| ())
.map_err(|_| "评论过于频繁,请稍后再试".to_string())
}
#[cfg(feature = "server")]
/// 检查图片访问请求是否超出限流阈值,返回 HTTP 状态码。
pub fn check_image_limit(ip: &str) -> Result<(), StatusCode> {
IMAGE_LIMITER
.check_key(&ip.to_string())
.map(|_| ())
.map_err(|_| StatusCode::TOO_MANY_REQUESTS)
}
#[cfg(feature = "server")]
fn trusted_proxy_count() -> usize {
std::env::var("TRUSTED_PROXY_COUNT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(0)
}
#[cfg(feature = "server")]
fn is_valid_ip(ip: &str) -> bool {
ip.parse::<std::net::IpAddr>().is_ok()
}
#[cfg(feature = "server")]
fn ip_from_x_forwarded_for(value: &str, trusted_proxy_count: usize) -> Option<String> {
// X-Forwarded-For 格式client, proxy1, proxy2, ..., proxyN
// 越靠右的地址离服务端越近。
let parts: Vec<&str> = value
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
if trusted_proxy_count == 0 || parts.len() <= trusted_proxy_count {
return None;
}
// 真实客户端 IP 位于右侧第 trusted_proxy_count + 1 个。
let idx = parts.len() - 1 - trusted_proxy_count;
let ip = parts[idx].to_string();
if is_valid_ip(&ip) {
Some(ip)
} else {
None
}
}
#[cfg(feature = "server")]
fn ip_from_x_real_ip(value: &str) -> Option<String> {
let ip = value.trim().to_string();
if is_valid_ip(&ip) {
Some(ip)
} else {
None
}
}
#[cfg(feature = "server")]
fn get_client_ip_internal(
headers: &http::HeaderMap,
trusted: usize,
peer: Option<std::net::SocketAddr>,
) -> String {
if trusted > 0 {
if let Some(value) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
if let Some(ip) = ip_from_x_forwarded_for(value, trusted) {
return ip;
}
}
if let Some(ip) = headers
.get("x-real-ip")
.and_then(|v| v.to_str().ok())
.and_then(ip_from_x_real_ip)
{
return ip;
}
}
if let Some(addr) = peer {
return addr.ip().to_string();
}
// Server function 等非 Axum 上下文无法获取对端地址,退回到 unknown。
// 此时所有请求共享一个限流桶,生产环境应在反向代理后部署。
tracing::warn!(
"无法获取客户端真实 IP未配置 TRUSTED_PROXY_COUNT 且无法读取 TCP 对端地址),\
限流将按 'unknown' 键聚合"
);
"unknown".to_string()
}
#[cfg(feature = "server")]
/// 根据信任代理层数从请求头中提取客户端 IP并校验 IP 合法性。
///
/// 当未配置可信代理时,不会信任任何 `X-Forwarded-For` / `X-Real-IP` 头,
/// 而是直接返回 `peer` 中的 TCP 对端地址(如果提供)。
pub fn get_client_ip_with_peer(
headers: &http::HeaderMap,
peer: Option<std::net::SocketAddr>,
) -> String {
get_client_ip_internal(headers, trusted_proxy_count(), peer)
}
#[cfg(feature = "server")]
/// 使用环境变量配置的代理层数提取客户端 IP。
///
/// 适用于 Dioxus server function 等无法获取 `ConnectInfo` 的场景。
/// 生产环境建议配合反向代理与 `TRUSTED_PROXY_COUNT` 使用。
pub fn get_client_ip(headers: &http::HeaderMap) -> String {
get_client_ip_internal(headers, trusted_proxy_count(), None)
}
#[cfg(feature = "server")]
/// 检查严格限流(注册、登录等敏感接口)。
///
/// 当 IP 为 "unknown"(无法识别真实客户端,通常是 TRUSTED_PROXY_COUNT=0
/// 且调用方为 Dioxus server function 时)改用宽松桶,避免所有匿名请求共享
/// 严格桶导致正常用户被误杀。生产环境配好 TRUSTED_PROXY_COUNT 后走真实 IP
/// 始终命中严格桶。
pub fn check_strict_limit(ip: &str) -> Result<(), String> {
if ip == "unknown" {
UNKNOWN_BUCKET_LIMITER
.check_key(&ip.to_string())
.map(|_| ())
.map_err(|_| "服务繁忙,请稍后再试".to_string())
} else {
STRICT_LIMITER
.check_key(&ip.to_string())
.map(|_| ())
.map_err(|_| "请求过于频繁,请稍后再试".to_string())
}
}
#[cfg(feature = "server")]
/// 检查上传请求是否超出限流阈值。
pub fn check_upload_limit(ip: &str) -> Result<(), String> {
UPLOAD_LIMITER
.check_key(&ip.to_string())
.map(|_| ())
.map_err(|_| "上传过于频繁,请稍后再试".to_string())
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
use http::HeaderMap;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
#[test]
fn get_client_ip_from_x_forwarded_for_with_one_trusted_proxy() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", "1.2.3.4, 5.6.7.8".parse().unwrap());
assert_eq!(
get_client_ip_with_trusted_and_peer(&headers, 1, None),
"1.2.3.4"
);
}
#[test]
fn get_client_ip_from_x_forwarded_for_with_two_trusted_proxies() {
let mut headers = HeaderMap::new();
headers.insert(
"x-forwarded-for",
"1.2.3.4, 5.6.7.8, 9.10.11.12".parse().unwrap(),
);
assert_eq!(
get_client_ip_with_trusted_and_peer(&headers, 2, None),
"1.2.3.4"
);
}
#[test]
fn get_client_ip_ignores_x_forwarded_for_when_no_trusted_proxies() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", "1.2.3.4, 5.6.7.8".parse().unwrap());
assert_eq!(
get_client_ip_with_trusted_and_peer(&headers, 0, None),
"unknown"
);
}
#[test]
fn get_client_ip_falls_back_to_peer_when_no_trusted_proxies() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", "1.2.3.4, 5.6.7.8".parse().unwrap());
let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 12345);
assert_eq!(
get_client_ip_with_trusted_and_peer(&headers, 0, Some(peer)),
"127.0.0.1"
);
}
#[test]
fn get_client_ip_from_x_real_ip_when_trusted() {
let mut headers = HeaderMap::new();
headers.insert("x-real-ip", "9.8.7.6".parse().unwrap());
assert_eq!(
get_client_ip_with_trusted_and_peer(&headers, 1, None),
"9.8.7.6"
);
}
#[test]
fn get_client_ip_x_real_ip_ignored_when_not_trusted() {
let mut headers = HeaderMap::new();
headers.insert("x-real-ip", "9.8.7.6".parse().unwrap());
let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)), 12345);
assert_eq!(
get_client_ip_with_trusted_and_peer(&headers, 0, Some(peer)),
"192.168.1.1"
);
}
#[test]
fn get_client_ip_x_forwarded_for_takes_priority_over_x_real_ip() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", "1.1.1.1, 2.2.2.2".parse().unwrap());
headers.insert("x-real-ip", "3.3.3.3".parse().unwrap());
assert_eq!(
get_client_ip_with_trusted_and_peer(&headers, 1, None),
"1.1.1.1"
);
}
#[test]
fn get_client_ip_no_headers_returns_unknown() {
let headers = HeaderMap::new();
assert_eq!(
get_client_ip_with_trusted_and_peer(&headers, 1, None),
"unknown"
);
}
#[test]
fn get_client_ip_ignores_short_x_forwarded_for_list() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", "1.2.3.4".parse().unwrap());
assert_eq!(
get_client_ip_with_trusted_and_peer(&headers, 2, None),
"unknown"
);
}
#[test]
fn get_client_ip_ignores_x_forwarded_for_equal_to_proxy_count() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", "1.2.3.4, 5.6.7.8".parse().unwrap());
assert_eq!(
get_client_ip_with_trusted_and_peer(&headers, 2, None),
"unknown"
);
}
#[test]
fn get_client_ip_ignores_empty_x_forwarded_for_entries() {
let mut headers = HeaderMap::new();
headers.insert(
"x-forwarded-for",
" , 1.2.3.4 , 5.6.7.8 , ".parse().unwrap(),
);
assert_eq!(
get_client_ip_with_trusted_and_peer(&headers, 1, None),
"1.2.3.4"
);
}
#[test]
fn get_client_ip_rejects_invalid_x_forwarded_for_value() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", "not-an-ip, 5.6.7.8".parse().unwrap());
assert_eq!(
get_client_ip_with_trusted_and_peer(&headers, 1, None),
"unknown"
);
}
#[test]
fn get_client_ip_rejects_invalid_x_real_ip_value() {
let mut headers = HeaderMap::new();
headers.insert("x-real-ip", "not-an-ip".parse().unwrap());
assert_eq!(
get_client_ip_with_trusted_and_peer(&headers, 1, None),
"unknown"
);
}
#[test]
fn get_client_ip_prefers_xff_over_peer() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", "1.2.3.4, 5.6.7.8".parse().unwrap());
let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 12345);
assert_eq!(
get_client_ip_with_trusted_and_peer(&headers, 1, Some(peer)),
"1.2.3.4"
);
}
#[test]
fn get_client_ip_with_env_trusted_proxy_count_zero() {
let original = std::env::var("TRUSTED_PROXY_COUNT").ok();
std::env::set_var("TRUSTED_PROXY_COUNT", "0");
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", "1.2.3.4, 5.6.7.8".parse().unwrap());
assert_eq!(get_client_ip(&headers), "unknown");
match original {
Some(value) => std::env::set_var("TRUSTED_PROXY_COUNT", value),
None => std::env::remove_var("TRUSTED_PROXY_COUNT"),
}
}
#[test]
#[serial_test::serial]
fn check_strict_unknown_ip_uses_lenient_bucket() {
// "unknown" 桶 burst 为 100少量请求应全部放行不被严格桶误杀。
// 用 serial 隔离,因为 UNKNOWN_BUCKET_LIMITER 是全局状态。
for _ in 0..20 {
assert!(
super::check_strict_limit("unknown").is_ok(),
"unknown bucket should allow small bursts, not hit strict 1 req/s limit"
);
}
}
#[test]
#[serial_test::serial]
fn check_strict_real_ip_uses_strict_bucket() {
// 真实 IP 命中严格桶1 req/s, burst 5。连发超过 burst 应被限流。
// 用一个唯一的 IP 避免与其他测试状态冲突。
let unique_ip = "198.51.100.42";
let mut allowed = 0;
let mut blocked = false;
for _ in 0..50 {
match super::check_strict_limit(unique_ip) {
Ok(()) => allowed += 1,
Err(_) => blocked = true,
}
if blocked {
break;
}
}
assert!(blocked, "strict bucket should eventually block real IP burst");
assert!(
allowed <= 6,
"strict burst is 5, allowed should be <= 6, got {allowed}"
);
}
// 测试辅助函数:绕过环境变量读取,直接指定 trusted_proxy_count。
fn get_client_ip_with_trusted_and_peer(
headers: &HeaderMap,
trusted: usize,
peer: Option<SocketAddr>,
) -> String {
get_client_ip_internal(headers, trusted, peer)
}
}