diff --git a/backend/crates/gateway-api/src/admin/auth.rs b/backend/crates/gateway-api/src/admin/auth.rs index 6d7c96a47..0a7ad0fc2 100644 --- a/backend/crates/gateway-api/src/admin/auth.rs +++ b/backend/crates/gateway-api/src/admin/auth.rs @@ -216,9 +216,11 @@ where .route("/api/admin/auth/logout", post(logout::)) } -/// 登录失败限流以 socket 对端 IP 为计数桶,使不同来源的失败互不影响; +/// 登录失败限流以 socket 对端为计数桶,使不同来源的失败互不影响; /// 转发头(X-Forwarded-For 等)可由客户端伪造,不参与来源判定。 /// 经反向代理部署时所有请求同源于代理地址,退化为共享单桶。 +/// IPv6 按 /64 前缀聚合——单前缀内 2^64 地址不应各得独立配额,否则 +/// 直连 IPv6 客户端可逐地址轮换绕过限流。 async fn attach_login_source(mut request: Request, next: Next) -> Response { if let Some(ConnectInfo(peer)) = request .extensions() @@ -227,11 +229,21 @@ async fn attach_login_source(mut request: Request, next: Next) -> Response { { request .extensions_mut() - .insert(AdminLoginSource::new(peer.ip().to_string())); + .insert(AdminLoginSource::new(login_bucket_key(peer.ip()))); } next.run(request).await } +fn login_bucket_key(ip: std::net::IpAddr) -> String { + match ip { + std::net::IpAddr::V4(v4) => v4.to_string(), + std::net::IpAddr::V6(v6) => { + let prefix = u128::from(v6) & !((1_u128 << 64) - 1); + format!("{}/64", std::net::Ipv6Addr::from(prefix)) + } + } +} + async fn login( State(state): State, source: Option>, diff --git a/backend/crates/gateway-api/tests/admin/auth.rs b/backend/crates/gateway-api/tests/admin/auth.rs index 62b49677c..3759d4974 100644 --- a/backend/crates/gateway-api/tests/admin/auth.rs +++ b/backend/crates/gateway-api/tests/admin/auth.rs @@ -226,3 +226,62 @@ async fn login_route_should_bucket_throttling_by_client_ip() { axum::http::StatusCode::UNAUTHORIZED ); } + +#[tokio::test] +async fn login_route_should_aggregate_ipv6_sources_by_64_prefix() { + use std::net::{Ipv6Addr, SocketAddr}; + + use axum::{body::Body, extract::ConnectInfo, http::Request}; + use tower::ServiceExt as _; + + #[derive(Clone)] + struct AuthState(gateway_admin::AdminServices); + + impl gateway_api::admin::AdminSessionState for AuthState { + fn admin_services(&self) -> &gateway_admin::AdminServices { + &self.0 + } + } + + let fixture = AdminTestFixture::new().await; + let app = gateway_api::admin::auth::router::() + .with_state(AuthState(fixture.services.clone())); + + let failed_login_from = |ip: Ipv6Addr| { + let app = app.clone(); + async move { + let mut request = Request::builder() + .method("POST") + .uri("/api/admin/auth/login") + .header("content-type", "application/json") + .body(Body::from( + r#"{"username":"admin_1","password":"wrong-password"}"#, + )) + .expect("build login request"); + request + .extensions_mut() + .insert(ConnectInfo(SocketAddr::from((ip, 50_000)))); + app.oneshot(request).await.expect("login response").status() + } + }; + + // 同一 /64 内轮换低 64 位不应各得独立配额:五次即触发限流。 + for suffix in 0..4 { + assert_eq!( + failed_login_from(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, suffix)).await, + axum::http::StatusCode::UNAUTHORIZED + ); + } + assert_eq!( + failed_login_from(Ipv6Addr::new( + 0x2001, 0xdb8, 0, 0, 0xffff, 0xffff, 0xffff, 0xffff + )) + .await, + axum::http::StatusCode::TOO_MANY_REQUESTS + ); + // 不同 /64 前缀是独立桶。 + assert_eq!( + failed_login_from(Ipv6Addr::new(0x2001, 0xdb8, 0, 1, 0, 0, 0, 1)).await, + axum::http::StatusCode::UNAUTHORIZED + ); +} diff --git a/backend/crates/gateway-store/tests/support.rs b/backend/crates/gateway-store/tests/support.rs index 24c57bef8..7912ca27b 100644 --- a/backend/crates/gateway-store/tests/support.rs +++ b/backend/crates/gateway-store/tests/support.rs @@ -5,8 +5,16 @@ pub(crate) fn test_env(name: &str) -> Option { let value = std::env::var(name).ok(); assert!( - value.is_some() || std::env::var_os("CI").is_none(), + value.is_some() || !running_in_ci(), "{name} must be set in CI: store integration tests are not allowed to skip silently" ); value } + +/// 按 is-ci 约定判定 CI:`CI` 存在且非 falsey(`0`/`false`/空)。 +/// 开发者用 `CI=false` 显式声明「非 CI」时不应把跳过变成 panic。 +fn running_in_ci() -> bool { + std::env::var("CI") + .ok() + .is_some_and(|value| !matches!(value.as_str(), "" | "0" | "false")) +}