use ahash::AHashMap;
use axum::{
Router,
body::Body,
extract::ConnectInfo,
http::{
Request, StatusCode,
header::{AUTHORIZATION, RETRY_AFTER},
},
middleware::from_fn,
routing::{delete, get, post, put},
};
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use super::{config::AdminApiConfig, handlers, server::AppState};
#[derive(Debug, Clone)]
pub struct OperatorIdentity(pub String);
type RateBucketMap = AHashMap<(String, String), (u64, Instant)>;
type RateBuckets = Arc<Mutex<RateBucketMap>>;
const RATE_BUCKET_MAX_ENTRIES: usize = 10_000;
fn constant_time_eq(a: &str, b: &str) -> bool {
if a.len() != b.len() {
return false;
}
let mut result = 0u8;
for (x, y) in a.bytes().zip(b.bytes()) {
result |= x ^ y;
}
result == 0
}
fn lock_rate_buckets(buckets: &Mutex<RateBucketMap>) -> std::sync::MutexGuard<'_, RateBucketMap> {
buckets.lock().unwrap_or_else(|poisoned| {
log::warn!(
target: "admin-api",
"rate_buckets mutex was poisoned by a previous panic; \
recovering inner state (counters may be stale until window reset)"
);
poisoned.into_inner()
})
}
fn group_for_path(path: &str) -> &'static str {
if path.starts_with("/api/v1/ban") {
"ban"
} else if path.starts_with("/api/v1/quota") {
"quota"
} else {
"default"
}
}
pub fn create_router(state: AppState, config: &AdminApiConfig) -> Router {
let mut router = Router::new()
.route("/api/v1/status", get(handlers::get_status))
.route("/api/v1/ban", post(handlers::create_ban))
.route("/api/v1/ban/{target}", delete(handlers::delete_ban))
.route("/api/v1/quota/{tenant_id}", put(handlers::update_quota))
.route(
"/api/v1/status/circuit-breaker",
get(handlers::get_circuit_breaker_status),
)
.with_state(state);
let api_key = config.api_key.clone();
let operator_mapping = config.api_key_operators.clone();
let rate_limits = config.rate_limits.clone();
let rate_buckets: RateBuckets = Arc::new(Mutex::new(AHashMap::new()));
router = router.layer(from_fn(
move |mut req: Request<Body>, next: axum::middleware::Next| {
let api_key = api_key.clone();
let operator_mapping = operator_mapping.clone();
let rate_limits = rate_limits.clone();
let rate_buckets = rate_buckets.clone();
async move {
let path = req.uri().path();
let group = group_for_path(path);
let client_ip = req
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|ci| ci.0.ip().to_string())
.unwrap_or_else(|| "unknown".to_string());
let (max, window_secs) = rate_limits.get(group).copied().unwrap_or((200, 60));
let window = Duration::from_secs(window_secs);
let now = Instant::now();
{
let mut buckets = lock_rate_buckets(&rate_buckets);
buckets.retain(|_, (_, start)| now.duration_since(*start) < window);
if buckets.len() >= RATE_BUCKET_MAX_ENTRIES {
if let Some(oldest) = buckets
.iter()
.min_by_key(|(_, (_, start))| *start)
.map(|(k, _)| k.clone())
{
buckets.remove(&oldest);
}
}
let key = (group.to_string(), client_ip);
let entry = buckets.entry(key).or_insert((0, now));
if now.duration_since(entry.1) >= window {
*entry = (1, now);
} else if entry.0 < max {
entry.0 += 1;
} else {
let retry_after = window - now.duration_since(entry.1);
let mut resp =
axum::response::Response::new(Body::from("Rate limit exceeded"));
*resp.status_mut() = StatusCode::TOO_MANY_REQUESTS;
if let Ok(val) = retry_after
.as_secs()
.to_string()
.parse::<http::HeaderValue>()
{
resp.headers_mut().insert(RETRY_AFTER, val);
}
return resp;
}
}
let auth_header = req
.headers()
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok());
let expected = format!("Bearer {}", api_key);
match auth_header {
Some(token) if constant_time_eq(token, &expected) => {
let raw_key = token.strip_prefix("Bearer ").unwrap_or(token);
let operator =
operator_mapping.get(raw_key).cloned().unwrap_or_else(|| {
log::warn!(
target: "admin-api",
"API key 未配置 operator 映射,回退到默认 'admin-api';\
建议通过 AdminApiConfig::with_api_key_operator 配置显式映射\
以防止 operator 身份伪造"
);
"admin-api".to_string()
});
req.extensions_mut().insert(OperatorIdentity(operator));
next.run(req).await
}
_ => {
let mut resp = axum::response::Response::new(Body::from("Invalid API key"));
*resp.status_mut() = StatusCode::UNAUTHORIZED;
resp
}
}
}
},
));
router
}
#[cfg(test)]
mod tests {
use super::*;
use crate::admin::AdminApiConfig;
use crate::admin::make_state;
#[cfg(feature = "ban-manager")]
use crate::admin::make_state_with_ban_manager;
use axum::body::Body;
use axum::http::{Request, StatusCode, header::AUTHORIZATION};
use http_body_util::BodyExt;
use tower::ServiceExt;
fn constant_time_eq(a: &str, b: &str) -> bool {
if a.len() != b.len() {
return false;
}
let mut result = 0u8;
for (x, y) in a.bytes().zip(b.bytes()) {
result |= x ^ y;
}
result == 0
}
fn make_create_ban_request(body: &str) -> Request<Body> {
Request::builder()
.uri("/api/v1/ban")
.method("POST")
.header(AUTHORIZATION, "Bearer test-api-key-16chars!!")
.header("Content-Type", "application/json")
.body(Body::from(body.to_string()))
.unwrap()
}
#[test]
fn test_constant_time_eq_equal_strings() {
assert!(constant_time_eq("hello", "hello"));
assert!(constant_time_eq("", ""));
assert!(constant_time_eq("Bearer abc123", "Bearer abc123"));
}
#[test]
fn test_medium_002_lock_recovers_from_poisoned_mutex() {
let mutex: Mutex<RateBucketMap> = Mutex::new(AHashMap::new());
let result = std::panic::catch_unwind(|| {
let _guard = mutex.lock().unwrap();
panic!("intentional poison for MEDIUM-002 test");
});
assert!(result.is_err(), "前置条件:mutex 应已被毒化");
let mut guard = lock_rate_buckets(&mutex);
assert!(guard.is_empty(), "恢复后的 guard 应能访问 map");
guard.insert(
("default".to_string(), "127.0.0.1".to_string()),
(1, Instant::now()),
);
assert_eq!(guard.len(), 1, "恢复后的 guard 应能正常写入");
}
#[test]
fn test_constant_time_eq_different_lengths() {
assert!(!constant_time_eq("hello", "hell"));
assert!(!constant_time_eq("a", "ab"));
assert!(!constant_time_eq("", "a"));
}
#[test]
fn test_constant_time_eq_different_content_same_length() {
assert!(!constant_time_eq("hello", "world"));
assert!(!constant_time_eq("abc", "abd"));
assert!(!constant_time_eq("Bearer xyz", "Bearer abc"));
}
#[tokio::test]
async fn test_router_rejects_missing_auth_header() {
let state = make_state().await;
let config = AdminApiConfig::new("test-api-key-16chars!!");
let app = create_router(state, &config);
let req = Request::builder()
.uri("/api/v1/status")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_router_rejects_wrong_api_key() {
let state = make_state().await;
let config = AdminApiConfig::new("test-api-key-16chars!!");
let app = create_router(state, &config);
let req = Request::builder()
.uri("/api/v1/status")
.header(AUTHORIZATION, "Bearer wrong-key")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_router_accepts_valid_api_key() {
let state = make_state().await;
let config = AdminApiConfig::new("test-api-key-16chars!!");
let app = create_router(state, &config);
let req = Request::builder()
.uri("/api/v1/status")
.header(AUTHORIZATION, "Bearer test-api-key-16chars!!")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["success"].as_bool().unwrap());
}
#[tokio::test]
async fn test_router_status_endpoint_returns_data() {
let state = make_state().await;
let config = AdminApiConfig::new("test-api-key-16chars!!");
let app = create_router(state, &config);
let req = Request::builder()
.uri("/api/v1/status")
.header(AUTHORIZATION, "Bearer test-api-key-16chars!!")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["data"]["total_requests"].as_u64().unwrap(), 0);
}
#[tokio::test]
async fn test_router_circuit_breaker_endpoint() {
let state = make_state().await;
let config = AdminApiConfig::new("test-api-key-16chars!!");
let app = create_router(state, &config);
let req = Request::builder()
.uri("/api/v1/status/circuit-breaker")
.header(AUTHORIZATION, "Bearer test-api-key-16chars!!")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(!json["success"].as_bool().unwrap());
}
#[cfg(feature = "ban-manager")]
#[tokio::test]
async fn test_create_ban_ip_success() {
let state = make_state_with_ban_manager().await;
let config = AdminApiConfig::new("test-api-key-16chars!!");
let app = create_router(state, &config);
let body = r#"{"target":{"type":"ip","value":"192.168.1.100"},"reason":"恶意请求"}"#;
let resp = app.oneshot(make_create_ban_request(body)).await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["success"].as_bool().unwrap());
assert!(json["data"]["id"].as_str().is_some());
assert_eq!(json["data"]["ban_times"].as_u64().unwrap(), 1);
assert!(json["data"]["is_manual"].as_bool().unwrap());
}
#[cfg(feature = "ban-manager")]
#[tokio::test]
async fn test_create_ban_user_success() {
let state = make_state_with_ban_manager().await;
let config = AdminApiConfig::new("test-api-key-16chars!!");
let app = create_router(state, &config);
let body = r#"{"target":{"type":"user","value":"user123"},"reason":"滥用行为"}"#;
let resp = app.oneshot(make_create_ban_request(body)).await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["success"].as_bool().unwrap());
}
#[cfg(feature = "ban-manager")]
#[tokio::test]
async fn test_create_ban_mac_success() {
let state = make_state_with_ban_manager().await;
let config = AdminApiConfig::new("test-api-key-16chars!!");
let app = create_router(state, &config);
let body = r#"{"target":{"type":"mac","value":"00:1a:2b:3c:4d:5e"},"reason":"设备封禁"}"#;
let resp = app.oneshot(make_create_ban_request(body)).await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["success"].as_bool().unwrap());
}
#[cfg(feature = "ban-manager")]
#[tokio::test]
async fn test_create_ban_geo_success() {
let state = make_state_with_ban_manager().await;
let config = AdminApiConfig::new("test-api-key-16chars!!");
let app = create_router(state, &config);
let body = r#"{"target":{"type":"geo","value":{"country_code":"CN"}},"reason":"地区封禁"}"#;
let resp = app.oneshot(make_create_ban_request(body)).await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["success"].as_bool().unwrap());
}
#[cfg(feature = "ban-manager")]
#[tokio::test]
async fn test_create_ban_invalid_ip_returns_400() {
let state = make_state_with_ban_manager().await;
let config = AdminApiConfig::new("test-api-key-16chars!!");
let app = create_router(state, &config);
let body = r#"{"target":{"type":"ip","value":"999.999.999.999"},"reason":"测试"}"#;
let resp = app.oneshot(make_create_ban_request(body)).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(!json["success"].as_bool().unwrap());
assert!(json["message"].as_str().unwrap().contains("IP"));
}
#[cfg(feature = "ban-manager")]
#[tokio::test]
async fn test_create_ban_invalid_geo_country_code_returns_400() {
let state = make_state_with_ban_manager().await;
let config = AdminApiConfig::new("test-api-key-16chars!!");
let app = create_router(state, &config);
let body = r#"{"target":{"type":"geo","value":{"country_code":"cn"}},"reason":"测试"}"#;
let resp = app.oneshot(make_create_ban_request(body)).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[cfg(feature = "ban-manager")]
#[tokio::test]
async fn test_create_ban_no_manager_returns_503() {
let state = make_state().await;
let config = AdminApiConfig::new("test-api-key-16chars!!");
let app = create_router(state, &config);
let body = r#"{"target":{"type":"ip","value":"1.2.3.4"},"reason":"测试"}"#;
let resp = app.oneshot(make_create_ban_request(body)).await.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(!json["success"].as_bool().unwrap());
}
#[cfg(feature = "ban-manager")]
#[tokio::test]
async fn test_create_ban_with_custom_duration() {
let state = make_state_with_ban_manager().await;
let config = AdminApiConfig::new("test-api-key-16chars!!");
let app = create_router(state, &config);
let body = r#"{"target":{"type":"ip","value":"10.0.0.5"},"reason":"自定义时长","duration_secs":3600}"#;
let resp = app.oneshot(make_create_ban_request(body)).await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["success"].as_bool().unwrap());
assert!(json["data"]["expires_at"].as_i64().unwrap() > 0);
}
#[cfg(feature = "ban-manager")]
#[tokio::test]
async fn test_create_ban_duplicate_returns_same_ban_times() {
let state = make_state_with_ban_manager().await;
let config = AdminApiConfig::new("test-api-key-16chars!!");
let app = create_router(state, &config);
let body = r#"{"target":{"type":"ip","value":"203.0.113.10"},"reason":"重复封禁"}"#;
let resp = app
.clone()
.oneshot(make_create_ban_request(body))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let resp_body = resp.into_body().collect().await.unwrap().to_bytes();
let json1: serde_json::Value = serde_json::from_slice(&resp_body).unwrap();
assert_eq!(json1["data"]["ban_times"].as_u64().unwrap(), 1);
let resp = app.oneshot(make_create_ban_request(body)).await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let resp_body = resp.into_body().collect().await.unwrap().to_bytes();
let json2: serde_json::Value = serde_json::from_slice(&resp_body).unwrap();
assert_eq!(json2["data"]["ban_times"].as_u64().unwrap(), 1);
}
#[cfg(feature = "ban-manager")]
#[tokio::test]
async fn test_create_ban_missing_reason_returns_422() {
let state = make_state_with_ban_manager().await;
let config = AdminApiConfig::new("test-api-key-16chars!!");
let app = create_router(state, &config);
let body = r#"{"target":{"type":"ip","value":"1.2.3.4"}}"#;
let resp = app.oneshot(make_create_ban_request(body)).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[cfg(feature = "ban-manager")]
#[tokio::test]
async fn test_create_ban_invalid_json_returns_400() {
let state = make_state_with_ban_manager().await;
let config = AdminApiConfig::new("test-api-key-16chars!!");
let app = create_router(state, &config);
let body = r#"{"target":{"type":"ip","value":"1.2.3.4"},"reason":"测试""#;
let resp = app.oneshot(make_create_ban_request(body)).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[cfg(feature = "ban-manager")]
#[tokio::test]
async fn test_create_ban_requires_auth() {
let state = make_state_with_ban_manager().await;
let config = AdminApiConfig::new("test-api-key-16chars!!");
let app = create_router(state, &config);
let req = Request::builder()
.uri("/api/v1/ban")
.method("POST")
.header("Content-Type", "application/json")
.body(Body::from(
r#"{"target":{"type":"ip","value":"1.2.3.4"},"reason":"测试"}"#.to_string(),
))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[cfg(feature = "ban-manager")]
#[tokio::test]
async fn test_vuln_0001_operator_from_api_key_mapping_ignores_body() {
use crate::BanManager;
use crate::authorization::SimpleAuthorizationProvider;
use std::sync::Arc;
let config = AdminApiConfig::new("test-api-key-16chars!!")
.with_api_key_operator("test-api-key-16chars!!", "admin-alice");
let auth_provider = Arc::new(SimpleAuthorizationProvider::new(vec![
"admin-alice".to_string(),
]));
let ban_manager = Arc::new(
BanManager::builder()
.with_authorization_provider(auth_provider)
.build()
.await
.unwrap(),
);
let governor = Arc::new(crate::admin::make_governor().await);
let state = AppState {
governor,
ban_manager: Some(ban_manager),
#[cfg(feature = "quota-control")]
quota_controller: None,
#[cfg(feature = "circuit-breaker")]
circuit_breaker: None,
};
let app = create_router(state, &config);
let body = r#"{"target":{"type":"ip","value":"198.51.100.1"},"reason":"vuln-0001 测试","operator":"admin-bob"}"#;
let resp = app.oneshot(make_create_ban_request(body)).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::CREATED,
"operator 必须来自 API key mapping(admin-alice),而非 body(admin-bob)"
);
}
#[cfg(feature = "ban-manager")]
#[tokio::test]
async fn test_vuln_0001_operator_falls_back_to_admin_api_when_mapping_absent() {
use crate::BanManager;
use crate::authorization::SimpleAuthorizationProvider;
use std::sync::Arc;
let config = AdminApiConfig::new("test-api-key-16chars!!");
let auth_provider = Arc::new(SimpleAuthorizationProvider::new(vec![
"admin-api".to_string(),
]));
let ban_manager = Arc::new(
BanManager::builder()
.with_authorization_provider(auth_provider)
.build()
.await
.unwrap(),
);
let governor = Arc::new(crate::admin::make_governor().await);
let state = AppState {
governor,
ban_manager: Some(ban_manager),
#[cfg(feature = "quota-control")]
quota_controller: None,
#[cfg(feature = "circuit-breaker")]
circuit_breaker: None,
};
let app = create_router(state, &config);
let body = r#"{"target":{"type":"ip","value":"198.51.100.2"},"reason":"fallback 测试","operator":"admin-evil"}"#;
let resp = app.oneshot(make_create_ban_request(body)).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::CREATED,
"未配置 mapping 时应回退到默认 'admin-api',而非 body 中的 operator"
);
}
#[cfg(feature = "ban-manager")]
#[tokio::test]
async fn test_vuln_0001_delete_ban_uses_mapped_operator() {
use crate::BanManager;
use crate::authorization::SimpleAuthorizationProvider;
use crate::ban::{BanSource, BanTarget};
use std::sync::Arc;
let config = AdminApiConfig::new("test-api-key-16chars!!")
.with_api_key_operator("test-api-key-16chars!!", "admin-alice");
let auth_provider = Arc::new(SimpleAuthorizationProvider::new(vec![
"admin-alice".to_string(),
]));
let ban_manager = Arc::new(
BanManager::builder()
.with_authorization_provider(auth_provider)
.build()
.await
.unwrap(),
);
let target = BanTarget::Ip("198.51.100.3".to_string());
ban_manager
.create_ban(
target.clone(),
"vuln-0001 unban 测试".to_string(),
BanSource::Manual {
operator: "admin-alice".to_string(),
},
serde_json::json!({}),
Some(std::time::Duration::from_secs(3600)),
)
.await
.unwrap();
let governor = Arc::new(crate::admin::make_governor().await);
let state = AppState {
governor,
ban_manager: Some(ban_manager),
#[cfg(feature = "quota-control")]
quota_controller: None,
#[cfg(feature = "circuit-breaker")]
circuit_breaker: None,
};
let app = create_router(state, &config);
let req = Request::builder()
.uri("/api/v1/ban/198.51.100.3")
.method("DELETE")
.header(AUTHORIZATION, "Bearer test-api-key-16chars!!")
.header("Content-Type", "application/json")
.body(Body::from(
r#"{"reason":"unban","operator":"admin-bob"}"#.to_string(),
))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"delete_ban 的 operator 必须来自 API key mapping(admin-alice),而非 body(admin-bob)"
);
}
#[tokio::test]
async fn test_vuln_0002_rate_limit_returns_429_after_max() {
let state = make_state().await;
let config =
AdminApiConfig::new("test-api-key-16chars!!").with_rate_limit("default", 3, 60);
let app = create_router(state, &config);
for i in 0..3 {
let req = Request::builder()
.uri("/api/v1/status")
.header(AUTHORIZATION, "Bearer test-api-key-16chars!!")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"request {} should succeed within rate limit",
i
);
}
let req = Request::builder()
.uri("/api/v1/status")
.header(AUTHORIZATION, "Bearer test-api-key-16chars!!")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::TOO_MANY_REQUESTS,
"第 4 次请求应被速率限制"
);
}
#[tokio::test]
async fn test_vuln_0002_rate_limit_includes_retry_after_header() {
let state = make_state().await;
let config =
AdminApiConfig::new("test-api-key-16chars!!").with_rate_limit("default", 1, 60);
let app = create_router(state, &config);
let req = Request::builder()
.uri("/api/v1/status")
.header(AUTHORIZATION, "Bearer test-api-key-16chars!!")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let req = Request::builder()
.uri("/api/v1/status")
.header(AUTHORIZATION, "Bearer test-api-key-16chars!!")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
let retry_after = resp
.headers()
.get(RETRY_AFTER)
.expect("429 响应必须包含 Retry-After header");
let retry_secs: u64 = retry_after.to_str().unwrap().parse().unwrap();
assert!(
retry_secs > 0,
"Retry-After 应为正数(剩余窗口时间),实际: {}",
retry_secs
);
}
#[tokio::test]
async fn test_vuln_0002_rate_limit_groups_independent() {
let state = make_state().await;
let config = AdminApiConfig::new("test-api-key-16chars!!")
.with_rate_limit("ban", 2, 60)
.with_rate_limit("default", 2, 60);
let app = create_router(state, &config);
for _ in 0..2 {
let req = Request::builder()
.uri("/api/v1/status")
.header(AUTHORIZATION, "Bearer test-api-key-16chars!!")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
let req = Request::builder()
.uri("/api/v1/status")
.header(AUTHORIZATION, "Bearer test-api-key-16chars!!")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::TOO_MANY_REQUESTS,
"default 分组配额耗尽应返回 429"
);
let req = Request::builder()
.uri("/api/v1/ban")
.method("POST")
.header(AUTHORIZATION, "Bearer test-api-key-16chars!!")
.header("Content-Type", "application/json")
.body(Body::from(
r#"{"target":{"type":"ip","value":"1.2.3.4"},"reason":"test"}"#.to_string(),
))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_ne!(
resp.status(),
StatusCode::TOO_MANY_REQUESTS,
"ban 分组应独立于 default 分组计数,不应返回 429"
);
}
#[tokio::test]
async fn test_high_001_per_client_isolation_different_ips() {
let state = make_state().await;
let config =
AdminApiConfig::new("test-api-key-16chars!!").with_rate_limit("default", 2, 60);
let app = create_router(state, &config);
let client_a: SocketAddr = "127.0.0.1:1234".parse().unwrap();
for i in 0..2 {
let mut req = Request::builder()
.uri("/api/v1/status")
.header(AUTHORIZATION, "Bearer test-api-key-16chars!!")
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(ConnectInfo(client_a));
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"client A request {} should succeed within its own bucket",
i
);
}
let mut req = Request::builder()
.uri("/api/v1/status")
.header(AUTHORIZATION, "Bearer test-api-key-16chars!!")
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(ConnectInfo(client_a));
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::TOO_MANY_REQUESTS,
"client A 第 3 次请求应被速率限制(自身配额耗尽)"
);
let client_b: SocketAddr = "192.168.1.1:5678".parse().unwrap();
let mut req = Request::builder()
.uri("/api/v1/status")
.header(AUTHORIZATION, "Bearer test-api-key-16chars!!")
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(ConnectInfo(client_b));
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"client B 应独立于 client A 计数,不应因 client A 耗尽配额而被限制"
);
}
#[tokio::test]
async fn test_high_001_same_ip_shares_bucket_across_ports() {
let state = make_state().await;
let config =
AdminApiConfig::new("test-api-key-16chars!!").with_rate_limit("default", 2, 60);
let app = create_router(state, &config);
let client_port1: SocketAddr = "127.0.0.1:1111".parse().unwrap();
let client_port2: SocketAddr = "127.0.0.1:2222".parse().unwrap();
let mut req = Request::builder()
.uri("/api/v1/status")
.header(AUTHORIZATION, "Bearer test-api-key-16chars!!")
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(ConnectInfo(client_port1));
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let mut req = Request::builder()
.uri("/api/v1/status")
.header(AUTHORIZATION, "Bearer test-api-key-16chars!!")
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(ConnectInfo(client_port2));
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let mut req = Request::builder()
.uri("/api/v1/status")
.header(AUTHORIZATION, "Bearer test-api-key-16chars!!")
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(ConnectInfo(client_port1));
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::TOO_MANY_REQUESTS,
"同 IP 不同端口应共享同一 rate bucket(按 IP 分桶,非按 socket 分桶)"
);
}
#[tokio::test]
async fn test_high_001_unknown_fallback_when_no_connect_info() {
let state = make_state().await;
let config =
AdminApiConfig::new("test-api-key-16chars!!").with_rate_limit("default", 1, 60);
let app = create_router(state, &config);
let req = Request::builder()
.uri("/api/v1/status")
.header(AUTHORIZATION, "Bearer test-api-key-16chars!!")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let req = Request::builder()
.uri("/api/v1/status")
.header(AUTHORIZATION, "Bearer test-api-key-16chars!!")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::TOO_MANY_REQUESTS,
"无 ConnectInfo 时应回退到 'unknown' 共享桶"
);
}
#[tokio::test]
async fn test_high_001_per_client_isolation_across_groups() {
let state = make_state().await;
let config = AdminApiConfig::new("test-api-key-16chars!!")
.with_rate_limit("ban", 1, 60)
.with_rate_limit("default", 1, 60);
let app = create_router(state, &config);
let client: SocketAddr = "10.0.0.1:9999".parse().unwrap();
let mut req = Request::builder()
.uri("/api/v1/ban")
.method("POST")
.header(AUTHORIZATION, "Bearer test-api-key-16chars!!")
.header("Content-Type", "application/json")
.body(Body::from(
r#"{"target":{"type":"ip","value":"1.2.3.4"},"reason":"test"}"#.to_string(),
))
.unwrap();
req.extensions_mut().insert(ConnectInfo(client));
let resp = app.clone().oneshot(req).await.unwrap();
assert_ne!(
resp.status(),
StatusCode::TOO_MANY_REQUESTS,
"首次 ban 请求不应被速率限制"
);
let mut req = Request::builder()
.uri("/api/v1/status")
.header(AUTHORIZATION, "Bearer test-api-key-16chars!!")
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(ConnectInfo(client));
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"同一 client 的不同 group 应独立计数"
);
}
}