fix(server): 限流中间件 fail-close 安全加固

RateLimitConfig 添加 fail_close 字段(默认 true),Redis 不可达时
拒绝请求返回 503 而非静默放行。开发环境可通过
ERP__RATE_LIMIT__FAIL_CLOSE=false 回退旧行为。
This commit is contained in:
iven
2026-05-11 10:22:05 +08:00
parent 8c347a5de9
commit 0f67f1c21f
3 changed files with 87 additions and 16 deletions

View File

@@ -66,4 +66,4 @@ secret_key = "dev-only-secret-key-change-in-production"
[rate_limit]
# Redis 不可达时是否拒绝请求fail-close。默认 true = 安全优先。
# 开发环境可设为 false 以避免 Redis 依赖ERP__RATE_LIMIT__FAIL_CLOSE=false
fail_close = false
fail_close = true

View File

@@ -159,8 +159,26 @@ impl StorageConfig {
}
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct RateLimitConfig {}
#[derive(Debug, Clone, Deserialize)]
pub struct RateLimitConfig {
/// Redis 不可达时是否拒绝请求fail-close
/// true = 安全优先Redis 故障时返回 503。
/// false = 可用性优先Redis 故障时放行。
#[serde(default = "default_fail_close")]
pub fail_close: bool,
}
fn default_fail_close() -> bool {
true
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
fail_close: default_fail_close(),
}
}
}
impl AppConfig {
pub fn load() -> anyhow::Result<Self> {

View File

@@ -29,7 +29,20 @@ pub async fn rate_limit_by_ip(
next: Next,
) -> Response {
let identifier = extract_client_ip(req.headers());
apply_rate_limit(&state.redis, &identifier, 5, 60, "login", req, next).await
let fail_close = state.config.rate_limit.fail_close;
apply_rate_limit(
RateLimitParams {
redis_client: &state.redis,
fail_close,
max_requests: 5,
window_secs: 60,
prefix: "login",
},
&identifier,
req,
next,
)
.await
}
/// 基于 Redis 的用户限流中间件。
@@ -45,25 +58,57 @@ pub async fn rate_limit_by_user(
.get::<erp_core::types::TenantContext>()
.map(|ctx| ctx.user_id.to_string())
.unwrap_or_else(|| "anonymous".to_string());
apply_rate_limit(&state.redis, &identifier, 300, 60, "api", req, next).await
let fail_close = state.config.rate_limit.fail_close;
apply_rate_limit(
RateLimitParams {
redis_client: &state.redis,
fail_close,
max_requests: 300,
window_secs: 60,
prefix: "api",
},
&identifier,
req,
next,
)
.await
}
/// Redis 不可达时的安全响应fail-close 模式)。
fn service_unavailable(prefix: &str) -> Response {
let body = RateLimitResponse {
error: "Service Unavailable".to_string(),
message: "服务暂时不可用,请稍后重试".to_string(),
};
tracing::error!("Redis 不可达fail-close 模式拒绝请求 [{}]", prefix);
(StatusCode::SERVICE_UNAVAILABLE, axum::Json(body)).into_response()
}
/// 限流参数,打包以避免函数签名过长。
struct RateLimitParams<'a> {
redis_client: &'a redis::Client,
fail_close: bool,
max_requests: u64,
window_secs: u64,
prefix: &'a str,
}
/// 执行限流检查。
async fn apply_rate_limit(
redis_client: &redis::Client,
params: RateLimitParams<'_>,
identifier: &str,
max_requests: u64,
window_secs: u64,
prefix: &str,
req: Request<Body>,
next: Next,
) -> Response {
let key = format!("rate_limit:{}:{}", prefix, identifier);
let key = format!("rate_limit:{}:{}", params.prefix, identifier);
let mut conn = match redis_client.get_multiplexed_async_connection().await {
let mut conn = match params.redis_client.get_multiplexed_async_connection().await {
Ok(c) => c,
Err(e) => {
tracing::error!(error = %e, "Redis 连接失败,限流放行 [{}]", prefix);
tracing::error!(error = %e, "Redis 连接失败 [{}]", params.prefix);
if params.fail_close {
return service_unavailable(params.prefix);
}
return next.run(req).await;
}
};
@@ -71,17 +116,20 @@ async fn apply_rate_limit(
let count: i64 = match redis::cmd("INCR").arg(&key).query_async(&mut conn).await {
Ok(n) => n,
Err(e) => {
tracing::error!(error = %e, "Redis INCR 失败,限流放行 [{}]", prefix);
tracing::error!(error = %e, "Redis INCR 失败 [{}]", params.prefix);
if params.fail_close {
return service_unavailable(params.prefix);
}
return next.run(req).await;
}
};
// 首次请求设置 TTL
if count == 1 {
let _: Result<(), _> = conn.expire(&key, window_secs as i64).await;
let _: Result<(), _> = conn.expire(&key, params.window_secs as i64).await;
}
if count > max_requests as i64 {
if count > params.max_requests as i64 {
let body = RateLimitResponse {
error: "Too Many Requests".to_string(),
message: "请求过于频繁,请稍后重试".to_string(),
@@ -104,11 +152,16 @@ pub async fn account_lockout_middleware(
req: Request<Body>,
next: Next,
) -> Response {
let fail_close = state.config.rate_limit.fail_close;
// 获取 Redis 连接
let mut conn = match state.redis.get_multiplexed_async_connection().await {
Ok(c) => c,
Err(e) => {
tracing::error!(error = %e, "Redis 连接失败,登录锁定放行");
tracing::error!(error = %e, "Redis 连接失败");
if fail_close {
return service_unavailable("login_lockout");
}
return next.run(req).await;
}
};