feat(security): harden client IP and admin controls

This commit is contained in:
elky
2026-07-10 15:13:12 +08:00
parent 6e0dc3b59e
commit bc1da3bf3f
19 changed files with 976 additions and 378 deletions
@@ -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))
+82 -11
View File
@@ -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;
}