mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 16:37:46 +08:00
feat(security): harden client IP and admin controls
This commit is contained in:
@@ -239,7 +239,7 @@ fn local_openai_responses_wrapper_applies_model_directive_before_body_rules() {
|
||||
)
|
||||
.expect("local openai responses body should build");
|
||||
|
||||
assert_eq!(provider_request_body["reasoning"]["effort"], "xhigh");
|
||||
assert_eq!(provider_request_body["reasoning"]["effort"], "max");
|
||||
assert_eq!(provider_request_body["reasoning"]["summary"], "auto");
|
||||
assert_eq!(provider_request_body["metadata"]["override_seen"], true);
|
||||
}
|
||||
|
||||
@@ -49,12 +49,13 @@ fn build_admin_security_not_found_response(detail: impl Into<String>) -> Respons
|
||||
}
|
||||
|
||||
fn admin_security_blacklist_ip_from_path(request_path: &str) -> Option<String> {
|
||||
let value = request_path
|
||||
.strip_prefix("/api/admin/security/ip/blacklist/")?
|
||||
.trim()
|
||||
.trim_matches('/')
|
||||
.to_string();
|
||||
if value.is_empty() || value.contains('/') {
|
||||
let value = decode_admin_security_path_value(
|
||||
request_path
|
||||
.strip_prefix("/api/admin/security/ip/blacklist/")?
|
||||
.trim()
|
||||
.trim_matches('/'),
|
||||
)?;
|
||||
if value.parse::<std::net::IpAddr>().is_err() {
|
||||
None
|
||||
} else {
|
||||
Some(value)
|
||||
@@ -62,18 +63,49 @@ fn admin_security_blacklist_ip_from_path(request_path: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
fn admin_security_whitelist_ip_from_path(request_path: &str) -> Option<String> {
|
||||
let value = request_path
|
||||
.strip_prefix("/api/admin/security/ip/whitelist/")?
|
||||
.trim()
|
||||
.trim_matches('/')
|
||||
.to_string();
|
||||
if value.is_empty() || value.contains('/') {
|
||||
let value = decode_admin_security_path_value(
|
||||
request_path
|
||||
.strip_prefix("/api/admin/security/ip/whitelist/")?
|
||||
.trim()
|
||||
.trim_matches('/'),
|
||||
)?;
|
||||
if !admin_security_validate_ip_or_cidr(&value) {
|
||||
None
|
||||
} else {
|
||||
Some(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_admin_security_path_value(value: &str) -> Option<String> {
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let bytes = value.as_bytes();
|
||||
let mut decoded = Vec::with_capacity(bytes.len());
|
||||
let mut index = 0;
|
||||
while index < bytes.len() {
|
||||
if bytes[index] == b'%' {
|
||||
let high = *bytes.get(index + 1)?;
|
||||
let low = *bytes.get(index + 2)?;
|
||||
decoded.push((decode_hex_digit(high)? << 4) | decode_hex_digit(low)?);
|
||||
index += 3;
|
||||
} else {
|
||||
decoded.push(bytes[index]);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
String::from_utf8(decoded).ok()
|
||||
}
|
||||
|
||||
fn decode_hex_digit(value: u8) -> Option<u8> {
|
||||
match value {
|
||||
b'0'..=b'9' => Some(value - b'0'),
|
||||
b'a'..=b'f' => Some(value - b'a' + 10),
|
||||
b'A'..=b'F' => Some(value - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_security_validate_ip_or_cidr(value: &str) -> bool {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
@@ -107,7 +139,12 @@ async fn build_admin_security_blacklist_add_response(
|
||||
));
|
||||
};
|
||||
let payload = match serde_json::from_slice::<AdminSecurityBlacklistAddRequest>(request_body) {
|
||||
Ok(value) if !value.ip_address.trim().is_empty() && !value.reason.trim().is_empty() => {
|
||||
Ok(value)
|
||||
if value.ip_address.trim().parse::<std::net::IpAddr>().is_ok()
|
||||
&& !value.reason.trim().is_empty()
|
||||
&& value.reason.trim().chars().count() <= 200
|
||||
&& value.ttl.is_none_or(|ttl| ttl > 0) =>
|
||||
{
|
||||
value
|
||||
}
|
||||
_ => {
|
||||
|
||||
@@ -643,7 +643,11 @@ fn count_active_admin_demotions(
|
||||
mutation: &AdminUserBatchMutation,
|
||||
items: &[AdminUserSelectionItem],
|
||||
) -> usize {
|
||||
if mutation.role.as_deref() != Some("user") {
|
||||
if mutation
|
||||
.role
|
||||
.as_deref()
|
||||
.is_none_or(crate::roles::is_full_admin_role)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
items
|
||||
@@ -659,7 +663,10 @@ fn batch_role_demotion_failure_reason(
|
||||
active_admin_demotions: usize,
|
||||
current_admin_user_id: Option<&str>,
|
||||
) -> Option<&'static str> {
|
||||
if mutation.role.as_deref() != Some("user")
|
||||
if mutation
|
||||
.role
|
||||
.as_deref()
|
||||
.is_none_or(crate::roles::is_full_admin_role)
|
||||
|| !item.is_active
|
||||
|| !item.role.eq_ignore_ascii_case("admin")
|
||||
{
|
||||
|
||||
@@ -150,6 +150,19 @@ pub(in super::super) async fn build_admin_update_user_response(
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
if existing_user.is_active
|
||||
&& crate::roles::is_full_admin_role(&existing_user.role)
|
||||
&& role
|
||||
.as_deref()
|
||||
.is_some_and(|role| !crate::roles::is_full_admin_role(role))
|
||||
&& state.count_active_admin_users().await? <= 1
|
||||
{
|
||||
return Ok((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "不能降级最后一个管理员账户" })),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
let effective_role = role.as_deref().unwrap_or(existing_user.role.as_str());
|
||||
let group_ids = if field_presence.contains("group_ids") {
|
||||
Some(normalize_admin_user_group_ids(payload.group_ids))
|
||||
|
||||
@@ -51,7 +51,7 @@ use crate::handlers::shared::{
|
||||
should_strip_forwarded_provider_credential_header, should_strip_forwarded_trusted_admin_header,
|
||||
};
|
||||
use crate::headers::{
|
||||
extract_or_generate_trace_id, request_origin_from_headers_and_remote_addr,
|
||||
effective_client_ip, extract_or_generate_trace_id, request_origin_from_headers_and_remote_addr,
|
||||
should_skip_request_header, RequestBodyNormalizationError,
|
||||
};
|
||||
use crate::router::RequestAdmissionError;
|
||||
@@ -417,7 +417,7 @@ fn api_key_remote_ip_allowed(ip_rules: Option<&[String]>, remote_ip: std::net::I
|
||||
|
||||
async fn maybe_promote_management_token_admin_principal(
|
||||
state: &AppState,
|
||||
remote_addr: &std::net::SocketAddr,
|
||||
client_ip: std::net::IpAddr,
|
||||
headers: &http::HeaderMap,
|
||||
trace_id: &str,
|
||||
request_context: &mut GatewayPublicRequestContext,
|
||||
@@ -451,7 +451,7 @@ async fn maybe_promote_management_token_admin_principal(
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if !remote_ip_allowed(token_with_user.token.allowed_ips.as_ref(), remote_addr.ip()) {
|
||||
if !remote_ip_allowed(token_with_user.token.allowed_ips.as_ref(), client_ip) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(user) = state.find_user_auth_by_id(&token_with_user.user.id).await? else {
|
||||
@@ -483,7 +483,7 @@ async fn maybe_promote_management_token_admin_principal(
|
||||
management_token_permissions,
|
||||
});
|
||||
|
||||
let remote_ip = remote_addr.ip().to_string();
|
||||
let remote_ip = client_ip.to_string();
|
||||
if let Err(err) = state
|
||||
.record_management_token_usage(&token_with_user.token.id, Some(remote_ip.as_str()))
|
||||
.await
|
||||
@@ -1039,6 +1039,51 @@ async fn proxy_request_inner(
|
||||
request: Request,
|
||||
) -> Result<Response<Body>, GatewayError> {
|
||||
let started_at = Instant::now();
|
||||
let client_ip = effective_client_ip(request.headers(), &remote_addr);
|
||||
let trace_id = extract_or_generate_trace_id(request.headers());
|
||||
match state.admin_security_ip_blacklisted(client_ip).await {
|
||||
Ok(true) => {
|
||||
warn!(
|
||||
event_name = "frontdoor_ip_blacklist_rejected",
|
||||
log_type = "event",
|
||||
trace_id = %trace_id,
|
||||
client_ip = %client_ip,
|
||||
path = %request.uri().path(),
|
||||
"gateway rejected blacklisted client IP"
|
||||
);
|
||||
let response = build_local_http_error_response(
|
||||
&trace_id,
|
||||
None,
|
||||
http::StatusCode::FORBIDDEN,
|
||||
"当前 IP 已被禁止访问",
|
||||
)?;
|
||||
return Ok(finalize_gateway_response(
|
||||
&state,
|
||||
response,
|
||||
&trace_id,
|
||||
&remote_addr,
|
||||
request.method(),
|
||||
request
|
||||
.uri()
|
||||
.path_and_query()
|
||||
.map(|value| value.as_str())
|
||||
.unwrap_or("/"),
|
||||
None,
|
||||
EXECUTION_PATH_LOCAL_AUTH_DENIED,
|
||||
&started_at,
|
||||
None,
|
||||
));
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(err) => warn!(
|
||||
event_name = "frontdoor_ip_blacklist_check_failed",
|
||||
log_type = "ops",
|
||||
trace_id = %trace_id,
|
||||
client_ip = %client_ip,
|
||||
error = ?err,
|
||||
"gateway failed open after IP blacklist check error"
|
||||
),
|
||||
}
|
||||
let accepted_at = request
|
||||
.extensions()
|
||||
.get::<crate::middleware::GatewayRequestAcceptedAt>()
|
||||
@@ -1175,7 +1220,7 @@ async fn proxy_request_inner(
|
||||
.await?;
|
||||
maybe_promote_management_token_admin_principal(
|
||||
&state,
|
||||
&remote_addr,
|
||||
client_ip,
|
||||
&parts.headers,
|
||||
&trace_id,
|
||||
&mut request_context,
|
||||
@@ -1186,9 +1231,9 @@ async fn proxy_request_inner(
|
||||
.as_ref()
|
||||
.and_then(|decision| decision.auth_context.as_ref())
|
||||
{
|
||||
if !api_key_remote_ip_allowed(auth_context.ip_rules.as_deref(), remote_addr.ip()) {
|
||||
if !api_key_remote_ip_allowed(auth_context.ip_rules.as_deref(), client_ip) {
|
||||
let rejection = crate::control::GatewayLocalAuthRejection::IpNotAllowed {
|
||||
remote_ip: remote_addr.ip().to_string(),
|
||||
remote_ip: client_ip.to_string(),
|
||||
};
|
||||
let response = build_local_auth_rejection_response(
|
||||
&trace_id,
|
||||
@@ -1528,10 +1573,36 @@ async fn proxy_request_inner(
|
||||
}
|
||||
|
||||
let rpm_started_at = Instant::now();
|
||||
let rate_limit_outcome = state
|
||||
.frontdoor_user_rpm()
|
||||
.check_and_consume(&state, control_decision)
|
||||
.await?;
|
||||
let ip_whitelist_applies =
|
||||
control_decision.and_then(|decision| decision.route_class.as_deref()) == Some("ai_public");
|
||||
let ip_whitelisted = if ip_whitelist_applies {
|
||||
state.admin_security_ip_whitelisted(client_ip).await
|
||||
} else {
|
||||
Ok(false)
|
||||
};
|
||||
let rate_limit_outcome = match ip_whitelisted {
|
||||
Ok(true) => FrontdoorUserRpmOutcome::NotApplicable,
|
||||
Ok(false) => {
|
||||
state
|
||||
.frontdoor_user_rpm()
|
||||
.check_and_consume(&state, control_decision)
|
||||
.await?
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
event_name = "frontdoor_ip_whitelist_check_failed",
|
||||
log_type = "ops",
|
||||
trace_id = %trace_id,
|
||||
client_ip = %client_ip,
|
||||
error = ?err,
|
||||
"gateway continued with rate limiting after IP whitelist check error"
|
||||
);
|
||||
state
|
||||
.frontdoor_user_rpm()
|
||||
.check_and_consume(&state, control_decision)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
observe_gateway_stage_ms("frontdoor_rpm", rpm_started_at.elapsed().as_millis() as u64);
|
||||
if let FrontdoorUserRpmOutcome::Rejected(rejection) = &rate_limit_outcome {
|
||||
let auth_context = control_decision.and_then(|decision| decision.auth_context.as_ref());
|
||||
|
||||
@@ -37,10 +37,10 @@ pub(crate) use self::email_templates::{
|
||||
};
|
||||
pub(crate) use self::external_models::OFFICIAL_EXTERNAL_MODEL_PROVIDERS;
|
||||
pub(crate) use self::normalize::{
|
||||
deserialize_optional_json_patch, deserialize_optional_string_list_patch, ip_rules_allow,
|
||||
json_ip_rules_allow, normalize_feature_settings, normalize_ip_rules, normalize_json_array,
|
||||
normalize_json_object, normalize_string_list, normalize_user_self_feature_settings_update,
|
||||
parse_json_ip_rules,
|
||||
deserialize_optional_json_patch, deserialize_optional_string_list_patch,
|
||||
ip_rule_pattern_matches, ip_rules_allow, json_ip_rules_allow, normalize_feature_settings,
|
||||
normalize_ip_rules, normalize_json_array, normalize_json_object, normalize_string_list,
|
||||
normalize_user_self_feature_settings_update, parse_json_ip_rules,
|
||||
};
|
||||
pub(crate) use self::payloads::{
|
||||
InternalGatewayAuthContextRequest, InternalGatewayExecuteRequest,
|
||||
|
||||
@@ -257,7 +257,7 @@ fn valid_ipv4_wildcard_pattern(pattern: &str) -> bool {
|
||||
.all(|part| *part == "*" || part.parse::<u8>().is_ok())
|
||||
}
|
||||
|
||||
fn ip_rule_pattern_matches(pattern: &str, remote_ip: IpAddr) -> bool {
|
||||
pub(crate) fn ip_rule_pattern_matches(pattern: &str, remote_ip: IpAddr) -> bool {
|
||||
if pattern == "*" {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
use std::{borrow::Cow, collections::BTreeMap, fmt, io::Read, net::SocketAddr, sync::LazyLock};
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
collections::BTreeMap,
|
||||
fmt,
|
||||
io::Read,
|
||||
net::{IpAddr, SocketAddr},
|
||||
sync::LazyLock,
|
||||
};
|
||||
|
||||
use crate::constants::*;
|
||||
use axum::body::Bytes;
|
||||
@@ -8,6 +15,7 @@ use uuid::Uuid;
|
||||
|
||||
const DEFAULT_MAX_REQUEST_BODY_MB: u64 = 64;
|
||||
const MAX_REQUEST_BODY_MB_ENV: &str = "AETHER_MAX_REQUEST_BODY_MB";
|
||||
const TRUSTED_PROXY_CIDRS_ENV: &str = "AETHER_TRUSTED_PROXY_CIDRS";
|
||||
|
||||
/// Upper bound applied to a request body after Content-Encoding decoding, and to
|
||||
/// uncompressed bodies as-is. Guards against decompression bombs and oversized
|
||||
@@ -21,6 +29,16 @@ static MAX_REQUEST_BODY_BYTES: LazyLock<u64> = LazyLock::new(|| {
|
||||
.saturating_mul(1024 * 1024)
|
||||
});
|
||||
|
||||
static TRUSTED_PROXY_CIDRS: LazyLock<Vec<String>> = LazyLock::new(|| {
|
||||
std::env::var(TRUSTED_PROXY_CIDRS_ENV)
|
||||
.unwrap_or_else(|_| "127.0.0.0/8,::1/128".to_string())
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty() && valid_ip_or_cidr(value))
|
||||
.map(ToOwned::to_owned)
|
||||
.collect()
|
||||
});
|
||||
|
||||
pub(crate) fn max_request_body_bytes() -> u64 {
|
||||
*MAX_REQUEST_BODY_BYTES
|
||||
}
|
||||
@@ -60,11 +78,96 @@ pub(crate) fn request_origin_from_headers_and_remote_addr(
|
||||
headers: &http::HeaderMap,
|
||||
remote_addr: &SocketAddr,
|
||||
) -> RequestOrigin {
|
||||
let mut origin = request_origin_from_headers(headers);
|
||||
if origin.client_ip.is_none() {
|
||||
origin.client_ip = Some(remote_addr.ip().to_string());
|
||||
RequestOrigin {
|
||||
client_ip: Some(effective_client_ip(headers, remote_addr).to_string()),
|
||||
user_agent: header_value_str(headers, http::header::USER_AGENT.as_str())
|
||||
.map(|value| truncate_chars(value.as_str(), 1_000)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn effective_client_ip(headers: &http::HeaderMap, remote_addr: &SocketAddr) -> IpAddr {
|
||||
let remote_ip = remote_addr.ip();
|
||||
if !trusted_proxy_ip(remote_ip) {
|
||||
return remote_ip;
|
||||
}
|
||||
|
||||
if let Some(real_ip) =
|
||||
header_value_str(headers, "x-real-ip").and_then(|value| value.parse::<IpAddr>().ok())
|
||||
{
|
||||
return real_ip;
|
||||
}
|
||||
|
||||
let forwarded_ips = header_value_str(headers, "x-forwarded-for")
|
||||
.map(|value| {
|
||||
value
|
||||
.split(',')
|
||||
.filter_map(|segment| segment.trim().parse::<IpAddr>().ok())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
forwarded_ips
|
||||
.iter()
|
||||
.rev()
|
||||
.copied()
|
||||
.find(|ip| !trusted_proxy_ip(*ip))
|
||||
.or_else(|| forwarded_ips.first().copied())
|
||||
.unwrap_or(remote_ip)
|
||||
}
|
||||
|
||||
fn trusted_proxy_ip(ip: IpAddr) -> bool {
|
||||
TRUSTED_PROXY_CIDRS
|
||||
.iter()
|
||||
.any(|pattern| ip_or_cidr_matches(pattern, ip))
|
||||
}
|
||||
|
||||
fn valid_ip_or_cidr(value: &str) -> bool {
|
||||
if value.parse::<IpAddr>().is_ok() {
|
||||
return true;
|
||||
}
|
||||
let Some((network, prefix)) = value.split_once('/') else {
|
||||
return false;
|
||||
};
|
||||
let Ok(network) = network.trim().parse::<IpAddr>() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(prefix) = prefix.trim().parse::<u8>() else {
|
||||
return false;
|
||||
};
|
||||
match network {
|
||||
IpAddr::V4(_) => prefix <= 32,
|
||||
IpAddr::V6(_) => prefix <= 128,
|
||||
}
|
||||
}
|
||||
|
||||
fn ip_or_cidr_matches(pattern: &str, ip: IpAddr) -> bool {
|
||||
if let Ok(expected) = pattern.parse::<IpAddr>() {
|
||||
return expected == ip;
|
||||
}
|
||||
let Some((network, prefix)) = pattern.split_once('/') else {
|
||||
return false;
|
||||
};
|
||||
let Ok(prefix) = prefix.trim().parse::<u8>() else {
|
||||
return false;
|
||||
};
|
||||
match (network.trim().parse::<IpAddr>(), ip) {
|
||||
(Ok(IpAddr::V4(network)), IpAddr::V4(ip)) if prefix <= 32 => {
|
||||
let mask = if prefix == 0 {
|
||||
0
|
||||
} else {
|
||||
u32::MAX << (32 - prefix)
|
||||
};
|
||||
(u32::from(network) & mask) == (u32::from(ip) & mask)
|
||||
}
|
||||
(Ok(IpAddr::V6(network)), IpAddr::V6(ip)) if prefix <= 128 => {
|
||||
let mask = if prefix == 0 {
|
||||
0
|
||||
} else {
|
||||
u128::MAX << (128 - prefix)
|
||||
};
|
||||
(u128::from(network) & mask) == (u128::from(ip) & mask)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
origin
|
||||
}
|
||||
|
||||
pub(crate) fn request_origin_from_parts(parts: &http::request::Parts) -> RequestOrigin {
|
||||
@@ -467,7 +570,7 @@ pub(crate) fn header_equals(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
decoded_request_body_bytes, normalize_request_body_headers_and_bytes,
|
||||
decoded_request_body_bytes, effective_client_ip, normalize_request_body_headers_and_bytes,
|
||||
request_origin_from_headers, request_origin_from_headers_and_remote_addr,
|
||||
tls_fingerprint_from_headers, RequestBodyNormalizationError, RequestOrigin,
|
||||
};
|
||||
@@ -504,6 +607,46 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_client_ip_ignores_forwarded_headers_from_untrusted_peers() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-real-ip", HeaderValue::from_static("198.51.100.4"));
|
||||
headers.insert("x-forwarded-for", HeaderValue::from_static("203.0.113.8"));
|
||||
let remote_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 10)), 443);
|
||||
|
||||
assert_eq!(
|
||||
effective_client_ip(&headers, &remote_addr),
|
||||
remote_addr.ip()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_client_ip_accepts_real_ip_from_trusted_loopback_proxy() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-real-ip", HeaderValue::from_static("198.51.100.4"));
|
||||
let remote_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 443);
|
||||
|
||||
assert_eq!(
|
||||
effective_client_ip(&headers, &remote_addr),
|
||||
IpAddr::V4(Ipv4Addr::new(198, 51, 100, 4))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_client_ip_walks_forwarded_chain_from_trusted_proxy() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-forwarded-for",
|
||||
HeaderValue::from_static("203.0.113.8, 127.0.0.2"),
|
||||
);
|
||||
let remote_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 443);
|
||||
|
||||
assert_eq!(
|
||||
effective_client_ip(&headers, &remote_addr),
|
||||
IpAddr::V4(Ipv4Addr::new(203, 0, 113, 8))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoded_request_body_bytes_decodes_zstd() {
|
||||
let payload = br#"{"model":"gpt-5.4"}"#;
|
||||
|
||||
@@ -1,7 +1,36 @@
|
||||
use crate::state::AdminSecurityBlacklistEntry;
|
||||
use crate::{AppState, GatewayError};
|
||||
use std::net::IpAddr;
|
||||
|
||||
impl AppState {
|
||||
pub(crate) async fn admin_security_ip_blacklisted(
|
||||
&self,
|
||||
ip_address: IpAddr,
|
||||
) -> Result<bool, GatewayError> {
|
||||
const ADMIN_SECURITY_BLACKLIST_PREFIX: &str = "ip:blacklist:";
|
||||
|
||||
self.runtime_state
|
||||
.kv_exists(&format!("{ADMIN_SECURITY_BLACKLIST_PREFIX}{ip_address}"))
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn admin_security_ip_whitelisted(
|
||||
&self,
|
||||
ip_address: IpAddr,
|
||||
) -> Result<bool, GatewayError> {
|
||||
const ADMIN_SECURITY_WHITELIST_KEY: &str = "ip:whitelist";
|
||||
|
||||
let rules = self
|
||||
.runtime_state
|
||||
.set_members(ADMIN_SECURITY_WHITELIST_KEY)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
Ok(rules
|
||||
.iter()
|
||||
.any(|rule| crate::handlers::shared::ip_rule_pattern_matches(rule.trim(), ip_address)))
|
||||
}
|
||||
|
||||
pub(crate) async fn add_admin_security_blacklist(
|
||||
&self,
|
||||
ip_address: &str,
|
||||
|
||||
@@ -355,7 +355,8 @@ fn openai_reasoning_effort_value(suffix: &str) -> Option<&'static str> {
|
||||
"low" => Some("low"),
|
||||
"medium" => Some("medium"),
|
||||
"high" => Some("high"),
|
||||
"xhigh" | "max" => Some("xhigh"),
|
||||
"xhigh" => Some("xhigh"),
|
||||
"max" => Some("max"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -457,6 +458,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_max_suffix_is_preserved_for_openai_formats() {
|
||||
assert_eq!(
|
||||
default_reasoning_mapping("openai:chat", "max"),
|
||||
Some(json!({ "reasoning_effort": "max" }))
|
||||
);
|
||||
assert_eq!(
|
||||
default_reasoning_mapping("openai:responses", "max"),
|
||||
Some(json!({ "reasoning": { "effort": "max" } }))
|
||||
);
|
||||
assert_eq!(
|
||||
default_reasoning_mapping("openai:responses:compact", "max"),
|
||||
Some(json!({ "reasoning": { "effort": "max" } }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_suffixes_are_order_insensitive() {
|
||||
let expected = Some(vec!["xhigh".to_string(), "fast".to_string()]);
|
||||
|
||||
@@ -4,8 +4,10 @@ use axum::body::{Body, Bytes};
|
||||
use axum::routing::any;
|
||||
use axum::{extract::Request, Router};
|
||||
use http::{HeaderMap, HeaderValue, StatusCode};
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::json;
|
||||
|
||||
use super::super::super::send_request;
|
||||
use super::super::{build_router_with_state, start_server, AppState};
|
||||
use crate::admin_api::{
|
||||
maybe_build_local_admin_security_response, AdminAppState, AdminRequestContext,
|
||||
@@ -17,6 +19,72 @@ use crate::constants::{
|
||||
};
|
||||
use crate::control::resolve_public_request_context;
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_blocks_blacklisted_ip_before_routing() {
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_admin_security_blacklist_for_tests([(
|
||||
"127.0.0.1".to_string(),
|
||||
"blocked".to_string(),
|
||||
)]),
|
||||
);
|
||||
let request = Request::builder()
|
||||
.uri("/api/public/system")
|
||||
.body(Body::empty())
|
||||
.expect("request should build");
|
||||
|
||||
let response = send_request(gateway, request).await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
let payload = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: serde_json::Value =
|
||||
serde_json::from_slice(&payload).expect("response should be json");
|
||||
assert_eq!(payload["error"]["message"], "当前 IP 已被禁止访问");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_blocks_forwarded_ip_from_trusted_proxy() {
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_admin_security_blacklist_for_tests([(
|
||||
"203.0.113.8".to_string(),
|
||||
"blocked".to_string(),
|
||||
)]),
|
||||
);
|
||||
let request = Request::builder()
|
||||
.uri("/api/public/system")
|
||||
.header("x-real-ip", "203.0.113.8")
|
||||
.body(Body::empty())
|
||||
.expect("request should build");
|
||||
|
||||
let response = send_request(gateway, request).await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_security_whitelist_matches_cidr() {
|
||||
let state = AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_admin_security_whitelist_for_tests(["203.0.113.0/24".to_string()]);
|
||||
|
||||
assert!(state
|
||||
.admin_security_ip_whitelisted("203.0.113.8".parse().expect("valid ip"))
|
||||
.await
|
||||
.expect("whitelist check should succeed"));
|
||||
assert!(!state
|
||||
.admin_security_ip_whitelisted("198.51.100.8".parse().expect("valid ip"))
|
||||
.await
|
||||
.expect("whitelist check should succeed"));
|
||||
}
|
||||
|
||||
async fn send_admin_security_request(
|
||||
gateway: Router,
|
||||
method: reqwest::Method,
|
||||
@@ -130,6 +198,26 @@ async fn gateway_handles_admin_security_blacklist_add_locally_with_trusted_admin
|
||||
assert_eq!(upstream_count, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_rejects_invalid_admin_security_blacklist_ip() {
|
||||
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
|
||||
|
||||
let (status, payload, upstream_count) = send_admin_security_request(
|
||||
gateway,
|
||||
reqwest::Method::POST,
|
||||
"/api/admin/security/ip/blacklist",
|
||||
Some(json!({
|
||||
"ip_address": "not-an-ip",
|
||||
"reason": "invalid"
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(payload["detail"], "请求数据验证失败");
|
||||
assert_eq!(upstream_count, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_admin_security_blacklist_add_attaches_explicit_audit() {
|
||||
let state = AppState::new().expect("gateway should build");
|
||||
@@ -311,6 +399,28 @@ async fn gateway_handles_admin_security_whitelist_remove_locally_with_trusted_ad
|
||||
assert_eq!(upstream_count, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_removes_percent_encoded_whitelist_cidr() {
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_admin_security_whitelist_for_tests(["10.0.0.0/24".to_string()]),
|
||||
);
|
||||
|
||||
let (status, payload, upstream_count) = send_admin_security_request(
|
||||
gateway,
|
||||
reqwest::Method::DELETE,
|
||||
"/api/admin/security/ip/whitelist/10.0.0.0%2F24",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(payload["success"], true);
|
||||
assert_eq!(payload["message"], "IP 10.0.0.0/24 已从白名单移除");
|
||||
assert_eq!(upstream_count, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_rejects_admin_security_whitelist_remove_without_ip_address() {
|
||||
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
|
||||
|
||||
@@ -1114,6 +1114,36 @@ async fn gateway_handles_admin_user_batch_actions_locally() {
|
||||
"不能降级最后一个管理员账户"
|
||||
);
|
||||
|
||||
let last_admin_audit_demotion_response = client
|
||||
.post(format!("{gateway_url}/api/admin/users/batch-action"))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.json(&json!({
|
||||
"selection": {
|
||||
"user_ids": ["user-1"]
|
||||
},
|
||||
"action": "update_role",
|
||||
"payload": {
|
||||
"role": "audit_admin"
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
assert_eq!(last_admin_audit_demotion_response.status(), StatusCode::OK);
|
||||
let last_admin_audit_demotion_payload: serde_json::Value = last_admin_audit_demotion_response
|
||||
.json()
|
||||
.await
|
||||
.expect("json body should parse");
|
||||
assert_eq!(last_admin_audit_demotion_payload["success"], 0);
|
||||
assert_eq!(last_admin_audit_demotion_payload["failed"], 1);
|
||||
assert_eq!(
|
||||
last_admin_audit_demotion_payload["failures"][0]["reason"],
|
||||
"不能降级最后一个管理员账户"
|
||||
);
|
||||
|
||||
let detail_response = client
|
||||
.get(format!("{gateway_url}/api/admin/users/user-1"))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
@@ -1299,6 +1329,40 @@ async fn gateway_handles_admin_user_detail_routes_locally_with_trusted_admin_pri
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_rejects_demoting_the_last_active_admin() {
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_auth_users_for_tests([sample_admin_user_with_role(
|
||||
"admin-1",
|
||||
"admin",
|
||||
"[email protected]",
|
||||
"admin",
|
||||
)]),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
for role in ["user", "audit_admin"] {
|
||||
let response = client
|
||||
.put(format!("{gateway_url}/api/admin/users/admin-1"))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.json(&json!({ "role": role }))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["detail"], "不能降级最后一个管理员账户");
|
||||
}
|
||||
|
||||
gateway_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_user_detail_locally_with_trusted_admin_principal() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
|
||||
Reference in New Issue
Block a user