fix(api,store): aggregate IPv6 login buckets by /64 and honor CI truthiness

Review follow-ups. The login throttle keyed on the full IPv6 /128 address,
so a direct-connect attacker with a routed /64 got an independent
five-guess bucket per address; bucket IPv6 sources by their /64 prefix so
the whole allocation shares one quota. IPv4 keying is unchanged, and a
router-level test pins both the aggregation and cross-prefix isolation.

The store test_env guard treated any present CI variable as CI, so a
developer running CI=false to signal not-CI turned skips into panics; parse
CI per the is-ci convention (present and not 0/false/empty).

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
zyycn
2026-07-27 00:17:02 +08:00
co-authored by Claude
parent c6444b2bf1
commit 9322b7aeb8
3 changed files with 82 additions and 3 deletions
+14 -2
View File
@@ -216,9 +216,11 @@ where
.route("/api/admin/auth/logout", post(logout::<S>))
}
/// 登录失败限流以 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<S>(
State(state): State<S>,
source: Option<Extension<AdminLoginSource>>,
@@ -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::<AuthState>()
.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
);
}
@@ -5,8 +5,16 @@
pub(crate) fn test_env(name: &str) -> Option<String> {
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"))
}