use crate::i18n::t;
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, AdminRole},
handlers,
server::LimiteronState,
};
#[derive(Debug, Clone)]
pub struct OperatorIdentity(pub String);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OperatorRole(pub AdminRole);
pub(crate) fn role_allows(role: AdminRole, method: &http::Method) -> bool {
if method == http::Method::GET || method == http::Method::HEAD {
role.allows_read()
} else {
role.allows_write()
}
}
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()
})
}
const PROBE_PATHS: [&str; 3] = ["/healthz", "/readyz", "/metrics"];
fn is_probe_path(path: &str) -> bool {
PROBE_PATHS.contains(&path)
}
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: LimiteronState, config: &AdminApiConfig) -> Router {
let mut router = Router::new()
.route("/healthz", get(handlers::healthz))
.route("/readyz", get(handlers::readyz))
.route("/metrics", get(handlers::metrics))
.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/{user_id}", put(handlers::update_quota))
.route(
"/api/v1/status/circuit-breaker",
get(handlers::get_circuit_breaker_status),
)
.route("/api/v1/introspect", get(handlers::introspect))
.route("/api/v1/config", post(handlers::apply_config))
.route("/api/v1/check/batch", post(handlers::check_batch))
.route("/api/v1/tokens/prefetch", post(handlers::prefetch_tokens))
.with_state(state);
let api_key = config.api_key.clone();
let operator_mapping = config.api_key_operators.clone();
let role_mapping = config.api_key_roles.clone();
let valid_keys = config.api_key_roles.keys().cloned().collect::<Vec<_>>();
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 role_mapping = role_mapping.clone();
let valid_keys = valid_keys.clone();
let rate_limits = rate_limits.clone();
let rate_buckets = rate_buckets.clone();
async move {
if is_probe_path(req.uri().path()) {
return next.run(req).await;
}
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
&& 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(
crate::i18n::t_simple("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);
let token = match auth_header {
Some(token)
if constant_time_eq(token, &expected)
|| valid_keys
.iter()
.any(|k| constant_time_eq(token, &format!("Bearer {k}"))) =>
{
Some(token.to_string())
}
_ => None,
};
let Some(token) = token else {
let mut resp = axum::response::Response::new(Body::from("Invalid API key"));
*resp.status_mut() = StatusCode::UNAUTHORIZED;
return resp;
};
let raw_key = token.strip_prefix("Bearer ").unwrap_or(&token).to_string();
let operator = operator_mapping.get(&raw_key).cloned().unwrap_or_else(|| {
log::warn!(
target: "admin-api",
"{}",
t("admin-api-key-operator-fallback", &[])
);
"admin-api".to_string()
});
req.extensions_mut().insert(OperatorIdentity(operator));
let role = role_mapping
.get(&raw_key)
.copied()
.unwrap_or(AdminRole::Admin);
if !role_allows(role, req.method()) {
log::warn!(
target: "admin-api",
"{}",
t(
"admin-rbac-denied",
&[
("role", role.as_str().to_string()),
("method", req.method().to_string()),
("path", req.uri().path().to_string()),
],
)
);
let mut resp = axum::response::Response::new(Body::from(
"Insufficient role for this operation",
));
*resp.status_mut() = StatusCode::FORBIDDEN;
return resp;
}
req.extensions_mut().insert(OperatorRole(role));
next.run(req).await
}
},
));
router
}
#[cfg(test)]
mod tests {
use super::*;
use crate::admin::AdminApiConfig;
use crate::admin::handlers::tests::make_state;
#[cfg(feature = "ban-manager")]
use crate::admin::handlers::tests::{TestDeps, make_governor, make_state_with};
use axum::body::Body;
use axum::http::{Request, StatusCode, header::AUTHORIZATION};
use http_body_util::BodyExt;
use tower::ServiceExt;
#[cfg(feature = "ban-manager")]
async fn make_state_with_ban_manager() -> LimiteronState {
use crate::BanManager;
let ban_manager = Arc::new(
BanManager::new()
.await
.expect("BanManager::new should succeed"),
);
make_state_with(
Arc::new(make_governor().await),
TestDeps {
ban_manager: Some(ban_manager),
..Default::default()
},
)
}
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
}
#[cfg(feature = "ban-manager")]
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(),
"precondition: mutex should already be poisoned"
);
let mut guard = lock_rate_buckets(&mutex);
assert!(guard.is_empty(), "the recovered guard must access the map");
guard.insert(
("default".to_string(), "127.0.0.1".to_string()),
(1, Instant::now()),
);
assert_eq!(guard.len(), 1, "the recovered guard must write normally");
}
#[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", "and"));
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_increments_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(), 2);
}
#[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(make_governor().await);
let state = LimiteronState {
governor,
ban_manager: Some(ban_manager),
#[cfg(feature = "quota-control")]
quota_controller: None,
#[cfg(feature = "circuit-breaker")]
circuit_breaker: None,
#[cfg(feature = "prometheus")]
metrics: 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 must come from the API key mapping (admin-alice), not the 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(make_governor().await);
let state = LimiteronState {
governor,
ban_manager: Some(ban_manager),
#[cfg(feature = "quota-control")]
quota_controller: None,
#[cfg(feature = "circuit-breaker")]
circuit_breaker: None,
#[cfg(feature = "prometheus")]
metrics: 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,
"without a mapping configured, fall back to the default 'admin-api' instead of the 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(make_governor().await);
let state = LimiteronState {
governor,
ban_manager: Some(ban_manager),
#[cfg(feature = "quota-control")]
quota_controller: None,
#[cfg(feature = "circuit-breaker")]
circuit_breaker: None,
#[cfg(feature = "prometheus")]
metrics: 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 must come from the API key mapping (admin-alice), not the 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,
"the 4th request must be rate limited"
);
}
#[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 response must contain the Retry-After header");
let retry_secs: u64 = retry_after.to_str().unwrap().parse().unwrap();
assert!(
retry_secs > 0,
"Retry-After must be positive (remaining window time), got: {}",
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 group quota exhaustion must return 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 group must count independently from the default group and not return 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's 3rd request must be rate limited (own quota exhausted)"
);
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 must count independently from client A and not be limited by client A's exhaustion"
);
}
#[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,
"same IP with different ports must share one rate bucket (bucketed by IP, not by 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,
"without ConnectInfo, fall back to a shared 'unknown' bucket"
);
}
#[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,
"the first ban request must not be rate limited"
);
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,
"different groups of the same client must count independently"
);
}
#[tokio::test]
async fn test_t605_viewer_key_can_read_status() {
let state = make_state().await;
let config = AdminApiConfig::new("primary-key-16chars!!!")
.with_api_key_role("viewer-key-16chars!!", AdminRole::Viewer);
let app = create_router(state, &config);
let req = Request::builder()
.uri("/api/v1/status")
.header(AUTHORIZATION, "Bearer viewer-key-16chars!!")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"viewer read endpoint should pass"
);
}
#[tokio::test]
async fn test_t605_viewer_key_write_ban_returns_403() {
let state = make_state().await;
let config = AdminApiConfig::new("primary-key-16chars!!!")
.with_api_key_role("viewer-key-16chars!!", AdminRole::Viewer);
let app = create_router(state, &config);
let req = Request::builder()
.uri("/api/v1/ban")
.method("POST")
.header(AUTHORIZATION, "Bearer viewer-key-16chars!!")
.header("Content-Type", "application/json")
.body(Body::from(
r#"{"target":{"type":"ip","value":"1.2.3.4"},"reason":"x"}"#.to_string(),
))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"viewer write endpoint must 403"
);
}
#[tokio::test]
async fn test_t605_viewer_key_delete_ban_returns_403() {
let state = make_state().await;
let config = AdminApiConfig::new("primary-key-16chars!!!")
.with_api_key_role("viewer-key-16chars!!", AdminRole::Viewer);
let app = create_router(state, &config);
let req = Request::builder()
.uri("/api/v1/ban/1.2.3.4")
.method("DELETE")
.header(AUTHORIZATION, "Bearer viewer-key-16chars!!")
.header("Content-Type", "application/json")
.body(Body::from(r#"{"reason":"x"}"#.to_string()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"viewer DELETE must 403"
);
}
#[cfg(feature = "ban-manager")]
#[tokio::test]
async fn test_t605_admin_key_write_ban_succeeds_and_viewer_cannot() {
let state = make_state_with_ban_manager().await;
let config = AdminApiConfig::new("primary-key-16chars!!!")
.with_api_key_role("viewer-key-16chars!!", AdminRole::Viewer);
let app = create_router(state, &config);
let req = Request::builder()
.uri("/api/v1/ban")
.method("POST")
.header(AUTHORIZATION, "Bearer primary-key-16chars!!!")
.header("Content-Type", "application/json")
.body(Body::from(
r#"{"target":{"type":"ip","value":"198.51.100.9"},"reason":"rbac"}"#.to_string(),
))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::CREATED,
"admin writing a ban should succeed"
);
let req = Request::builder()
.uri("/api/v1/ban")
.method("POST")
.header(AUTHORIZATION, "Bearer viewer-key-16chars!!")
.header("Content-Type", "application/json")
.body(Body::from(
r#"{"target":{"type":"ip","value":"198.51.100.10"},"reason":"rbac"}"#.to_string(),
))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn test_t605_unknown_key_still_401() {
let state = make_state().await;
let config = AdminApiConfig::new("primary-key-16chars!!!")
.with_api_key_role("viewer-key-16chars!!", AdminRole::Viewer);
let app = create_router(state, &config);
let req = Request::builder()
.uri("/api/v1/status")
.header(AUTHORIZATION, "Bearer rogue-key-not-registered")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"unregistered key should 401"
);
}
#[test]
fn test_t605_role_allows_matrix() {
use axum::http::Method;
assert!(role_allows(AdminRole::Admin, &Method::GET));
assert!(role_allows(AdminRole::Viewer, &Method::GET));
assert!(role_allows(AdminRole::Viewer, &Method::HEAD));
assert!(role_allows(AdminRole::Admin, &Method::POST));
assert!(!role_allows(AdminRole::Viewer, &Method::POST));
assert!(!role_allows(AdminRole::Viewer, &Method::PUT));
assert!(!role_allows(AdminRole::Viewer, &Method::DELETE));
}
#[tokio::test]
async fn test_t608_introspect_returns_json_snapshot() {
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/introspect")
.header(AUTHORIZATION, "Bearer test-api-key-16chars!!")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"introspection endpoint should be 200"
);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
let rules = json["rules"].as_array().expect("rules array");
assert!(
rules.iter().any(|r| r["id"] == "test_rule"),
"introspection must include the configured rules: {json}"
);
assert!(json["chains"].is_array(), "chains should be an array");
assert!(
json["stats"]["total_requests"].is_u64(),
"stats should be inlined"
);
assert!(
json["l1_cache"]["enabled"].is_boolean(),
"l1_cache status should be inlined"
);
assert!(
json["health"]["storage_healthy"].is_boolean(),
"health status should be inlined"
);
assert!(json["config_version"].is_string());
assert!(json["is_shutdown"].is_boolean());
}
#[tokio::test]
async fn test_t608_introspect_reflects_requests() {
let state = make_state().await;
let ctx = crate::matchers::RequestContext::new().with_path("/x");
let _ = state.governor.check(&ctx).await;
let snapshot = state.governor.introspect().await;
assert!(
snapshot.stats.total_requests >= 1,
"introspection stats must reflect requests that happened"
);
assert!(
snapshot
.rules
.iter()
.any(|r| r.limiters.iter().any(|k| k == "token_bucket")),
"rule limiter type should output the kind name"
);
}
}