mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 01:47:47 +08:00
feat(security): harden gateway boundaries and usage policies
Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change. Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
This commit is contained in:
@@ -2415,14 +2415,27 @@ mod tests {
|
||||
}
|
||||
|
||||
fn sample_codex_key() -> StoredProviderCatalogKey {
|
||||
let encrypted_auth_config = encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
r#"{"provider_type":"codex","refresh_token":"rt-codex-local-123"}"#,
|
||||
)
|
||||
.expect("auth config should encrypt");
|
||||
let provider_id = "provider-codex-cli-local-1";
|
||||
let key_id = "key-codex-cli-local-1";
|
||||
let credential_state = AppState::new()
|
||||
.expect("credential state should build")
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::disabled()
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
);
|
||||
let encrypted_api_key = credential_state
|
||||
.seal_provider_catalog_key_api_key(provider_id, key_id, "codex-access-token")
|
||||
.expect("access token should encrypt");
|
||||
let encrypted_auth_config = credential_state
|
||||
.seal_provider_catalog_key_auth_config(
|
||||
provider_id,
|
||||
key_id,
|
||||
r#"{"provider_type":"codex","refresh_token":"rt-codex-local-123"}"#,
|
||||
)
|
||||
.expect("auth config should encrypt");
|
||||
StoredProviderCatalogKey::new(
|
||||
"key-codex-cli-local-1".to_string(),
|
||||
"provider-codex-cli-local-1".to_string(),
|
||||
key_id.to_string(),
|
||||
provider_id.to_string(),
|
||||
"oauth".to_string(),
|
||||
"oauth".to_string(),
|
||||
None,
|
||||
@@ -2431,8 +2444,7 @@ mod tests {
|
||||
.expect("key should build")
|
||||
.with_transport_fields(
|
||||
Some(serde_json::json!(["openai:responses"])),
|
||||
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "codex-access-token")
|
||||
.expect("access token should encrypt"),
|
||||
encrypted_api_key,
|
||||
Some(encrypted_auth_config),
|
||||
None,
|
||||
Some(serde_json::json!({"openai:responses": 1})),
|
||||
@@ -3908,7 +3920,7 @@ mod tests {
|
||||
assert!(stored_key.oauth_invalid_at_unix_secs.is_some());
|
||||
assert_eq!(
|
||||
stored_key.oauth_invalid_reason.as_deref(),
|
||||
Some("[OAUTH_EXPIRED] session expired")
|
||||
Some("[OAUTH_EXPIRED] Codex Token 已过期")
|
||||
);
|
||||
assert_eq!(
|
||||
stored_key
|
||||
@@ -4220,7 +4232,7 @@ mod tests {
|
||||
assert!(stored_key.oauth_invalid_at_unix_secs.is_some());
|
||||
assert_eq!(
|
||||
stored_key.oauth_invalid_reason.as_deref(),
|
||||
Some("[OAUTH_EXPIRED] Codex Token 已失效 (403): forbidden")
|
||||
Some("[OAUTH_EXPIRED] Codex Token 已失效 (403)")
|
||||
);
|
||||
assert_eq!(
|
||||
stored_key
|
||||
@@ -4263,7 +4275,7 @@ mod tests {
|
||||
assert!(stored_key.oauth_invalid_at_unix_secs.is_some());
|
||||
assert_eq!(
|
||||
stored_key.oauth_invalid_reason.as_deref(),
|
||||
Some("[OAUTH_EXPIRED] Personal access token owner is inactive.")
|
||||
Some("[OAUTH_EXPIRED] Codex Token 已失效")
|
||||
);
|
||||
assert_eq!(
|
||||
stored_key
|
||||
@@ -4305,7 +4317,7 @@ mod tests {
|
||||
.expect("recoverable token invalidation should retain the key");
|
||||
assert_eq!(
|
||||
stored_key.oauth_invalid_reason.as_deref(),
|
||||
Some("[OAUTH_EXPIRED] Personal access token owner is inactive.")
|
||||
Some("[OAUTH_EXPIRED] Codex Token 已失效")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4365,7 +4377,7 @@ mod tests {
|
||||
.expect("recoverable expired token should be retained");
|
||||
assert_eq!(
|
||||
stored_key.oauth_invalid_reason.as_deref(),
|
||||
Some("[OAUTH_EXPIRED] session expired")
|
||||
Some("[OAUTH_EXPIRED] Codex Token 已过期")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4568,7 +4580,7 @@ mod tests {
|
||||
.expect("stored key should exist");
|
||||
assert_eq!(
|
||||
stored_key.oauth_invalid_reason.as_deref(),
|
||||
Some("[OAUTH_EXPIRED] session expired")
|
||||
Some("[OAUTH_EXPIRED] Codex Token 已过期")
|
||||
);
|
||||
assert!(stored_key.oauth_invalid_at_unix_secs.is_some());
|
||||
assert_eq!(
|
||||
@@ -5032,8 +5044,13 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_success_projection_is_rate_limited_until_failure_resets_gate() {
|
||||
let state = health_state();
|
||||
let plan = sample_plan();
|
||||
// Keep this test's process-wide persistence gate isolated from the other
|
||||
// effect tests, which intentionally exercise the same health key in parallel.
|
||||
let mut plan = sample_plan();
|
||||
plan.key_id = format!("health-success-rate-limit-{}", uuid::Uuid::new_v4());
|
||||
let mut key = sample_health_key();
|
||||
key.id = plan.key_id.clone();
|
||||
let state = health_state_with_key(key);
|
||||
|
||||
apply_local_execution_effect(
|
||||
&state,
|
||||
|
||||
@@ -264,7 +264,16 @@ fn mask_trace_header_value(name: &str, value: &str) -> String {
|
||||
if value.len() <= 8 {
|
||||
return "****".to_string();
|
||||
}
|
||||
format!("{}****{}", &value[..4], &value[value.len() - 4..])
|
||||
let prefix = value.chars().take(4).collect::<String>();
|
||||
let suffix = value
|
||||
.chars()
|
||||
.rev()
|
||||
.take(4)
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.rev()
|
||||
.collect::<String>();
|
||||
format!("{prefix}****{suffix}")
|
||||
}
|
||||
|
||||
fn trace_header_is_sensitive(name: &str) -> bool {
|
||||
@@ -280,3 +289,24 @@ fn trace_header_is_sensitive(name: &str) -> bool {
|
||||
.iter()
|
||||
.any(|candidate| name.trim().eq_ignore_ascii_case(candidate))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::mask_trace_header_value;
|
||||
|
||||
#[test]
|
||||
fn sensitive_header_masking_is_safe_for_unicode_values() {
|
||||
let masked = mask_trace_header_value("authorization", "令牌值-абвгдеж");
|
||||
assert!(masked.starts_with("令牌值-"));
|
||||
assert!(masked.contains("****"));
|
||||
assert!(masked.ends_with("гдеж"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensitive_header_masking_preserves_ascii_shape() {
|
||||
assert_eq!(
|
||||
mask_trace_header_value("x-api-key", "abcdefghijk"),
|
||||
"abcd****hijk"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,10 @@ use aether_admin::provider::quota as admin_provider_quota_pure;
|
||||
use aether_data_contracts::repository::provider_catalog::ProviderCatalogKeyRuntimeMetadataUpdate;
|
||||
use aether_provider_pool::grok_quota_window_key_for_model;
|
||||
use aether_usage_runtime::{
|
||||
extract_gemini_file_mapping_entries, gemini_file_mapping_cache_key, normalize_gemini_file_name,
|
||||
report_request_id, GatewayStreamReportRequest, GatewaySyncReportRequest,
|
||||
GEMINI_FILE_MAPPING_TTL_SECONDS,
|
||||
decode_internal_report_body_base64, extract_gemini_file_mapping_entries,
|
||||
gemini_file_mapping_cache_key, normalize_gemini_file_name, report_request_id,
|
||||
GatewayStreamReportRequest, GatewaySyncReportRequest, GEMINI_FILE_MAPPING_TTL_SECONDS,
|
||||
};
|
||||
use base64::Engine as _;
|
||||
use regex::Regex;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::warn;
|
||||
@@ -241,9 +240,7 @@ fn gemini_cli_credits_from_stream_payload(
|
||||
now_unix_secs: u64,
|
||||
) -> Option<Value> {
|
||||
let body_base64 = payload.provider_body_base64.as_deref()?;
|
||||
let body = base64::engine::general_purpose::STANDARD
|
||||
.decode(body_base64)
|
||||
.ok()?;
|
||||
let body = decode_internal_report_body_base64(body_base64).ok()?;
|
||||
let text = std::str::from_utf8(&body).ok()?;
|
||||
let mut latest = None::<Value>;
|
||||
for raw_line in text.lines() {
|
||||
@@ -272,9 +269,7 @@ fn codex_websocket_quota_from_stream_payload(
|
||||
now_unix_secs: u64,
|
||||
) -> Option<Value> {
|
||||
let body_base64 = payload.provider_body_base64.as_deref()?;
|
||||
let body = base64::engine::general_purpose::STANDARD
|
||||
.decode(body_base64)
|
||||
.ok()?;
|
||||
let body = decode_internal_report_body_base64(body_base64).ok()?;
|
||||
let text = std::str::from_utf8(&body).ok()?;
|
||||
let mut latest = None::<Value>;
|
||||
for raw_line in text.lines() {
|
||||
@@ -742,8 +737,30 @@ async fn apply_local_gemini_file_mapping_report_effect(
|
||||
let Some(file_name) = file_name else {
|
||||
return;
|
||||
};
|
||||
let user_id = payload
|
||||
.report_context
|
||||
.as_ref()
|
||||
.and_then(|context| context.get("user_id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let key_id = payload
|
||||
.report_context
|
||||
.as_ref()
|
||||
.and_then(|context| context.get("key_id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let Some(user_id) = user_id else {
|
||||
return;
|
||||
};
|
||||
let Some(key_id) = key_id else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(err) = delete_local_gemini_file_mapping(state, file_name.as_str()).await {
|
||||
if let Err(err) =
|
||||
delete_local_gemini_file_mapping(state, file_name.as_str(), key_id, user_id).await
|
||||
{
|
||||
warn!(
|
||||
event_name = "gemini_file_mapping_delete_failed",
|
||||
log_type = "ops",
|
||||
@@ -772,8 +789,8 @@ pub(crate) async fn store_local_gemini_file_mapping(
|
||||
};
|
||||
let expires_at_unix_secs = current_unix_secs().saturating_add(GEMINI_FILE_MAPPING_TTL_SECONDS);
|
||||
|
||||
let _stored = state
|
||||
.upsert_gemini_file_mapping(
|
||||
let stored = state
|
||||
.upsert_gemini_file_mapping_if_owner_matches(
|
||||
aether_data::repository::gemini_file_mappings::UpsertGeminiFileMappingRecord {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
file_name: file_name.clone(),
|
||||
@@ -786,6 +803,17 @@ pub(crate) async fn store_local_gemini_file_mapping(
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if stored.is_none() {
|
||||
warn!(
|
||||
event_name = "gemini_file_mapping_atomic_owner_mismatch",
|
||||
log_type = "security",
|
||||
file_name = %file_name,
|
||||
requested_key_id = %key_id,
|
||||
requested_user_id = user_id.unwrap_or_default(),
|
||||
"gateway refused to reassign a Gemini file mapping during the atomic write"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
state
|
||||
.cache_set_string_with_ttl(
|
||||
gemini_file_mapping_cache_key(file_name.as_str()).as_str(),
|
||||
@@ -799,14 +827,26 @@ pub(crate) async fn store_local_gemini_file_mapping(
|
||||
async fn delete_local_gemini_file_mapping(
|
||||
state: &AppState,
|
||||
file_name: &str,
|
||||
key_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<(), GatewayError> {
|
||||
let Some(file_name) = normalize_gemini_file_name(file_name) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let _deleted = state
|
||||
.delete_gemini_file_mapping_by_file_name(file_name.as_str())
|
||||
let deleted = state
|
||||
.delete_gemini_file_mapping_by_file_name_for_owner(file_name.as_str(), key_id, user_id)
|
||||
.await?;
|
||||
if !deleted {
|
||||
warn!(
|
||||
event_name = "gemini_file_mapping_atomic_delete_owner_mismatch",
|
||||
log_type = "security",
|
||||
file_name = %file_name,
|
||||
requested_key_id = %key_id,
|
||||
requested_user_id = %user_id,
|
||||
"gateway refused to delete a Gemini file mapping after its owner changed"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
state
|
||||
.cache_delete_key(gemini_file_mapping_cache_key(file_name.as_str()).as_str())
|
||||
.await?;
|
||||
|
||||
Reference in New Issue
Block a user