mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
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:
@@ -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"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user