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:
@@ -6,6 +6,13 @@ use chrono::{TimeZone, Utc};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::redaction::{
|
||||
admin_restore_secret_safe_body_rules, admin_restore_secret_safe_header_rules,
|
||||
admin_restore_secret_safe_json, admin_restore_secret_safe_proxy, admin_restore_secret_safe_url,
|
||||
admin_secret_safe_body_rules, admin_secret_safe_header_rules, admin_secret_safe_json,
|
||||
admin_secret_safe_proxy, admin_secret_safe_url,
|
||||
};
|
||||
|
||||
pub fn normalize_endpoint_api_format(api_format: &str) -> String {
|
||||
aether_ai_formats::normalize_api_format_alias(api_format)
|
||||
}
|
||||
@@ -213,21 +220,6 @@ mod endpoint_key_count_tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn masked_proxy_value(proxy: Option<&serde_json::Value>) -> serde_json::Value {
|
||||
let Some(proxy) = proxy.and_then(serde_json::Value::as_object) else {
|
||||
return serde_json::Value::Null;
|
||||
};
|
||||
let mut masked = proxy.clone();
|
||||
if masked
|
||||
.get("password")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
{
|
||||
masked.insert("password".to_string(), json!("***"));
|
||||
}
|
||||
serde_json::Value::Object(masked)
|
||||
}
|
||||
|
||||
fn endpoint_timestamp_or_now(value: Option<u64>, now_unix_secs: u64) -> serde_json::Value {
|
||||
unix_secs_to_rfc3339(value.unwrap_or(now_unix_secs))
|
||||
.map(serde_json::Value::String)
|
||||
@@ -246,15 +238,15 @@ pub fn build_admin_provider_endpoint_response(
|
||||
"provider_id": endpoint.provider_id,
|
||||
"provider_name": provider_name,
|
||||
"api_format": endpoint.api_format,
|
||||
"base_url": endpoint.base_url,
|
||||
"base_url": admin_secret_safe_url(Some(&endpoint.base_url)),
|
||||
"custom_path": endpoint.custom_path,
|
||||
"header_rules": endpoint.header_rules,
|
||||
"body_rules": endpoint.body_rules,
|
||||
"header_rules": admin_secret_safe_header_rules(endpoint.header_rules.as_ref()),
|
||||
"body_rules": admin_secret_safe_body_rules(endpoint.body_rules.as_ref()),
|
||||
"max_retries": endpoint.max_retries.unwrap_or(2),
|
||||
"is_active": endpoint.is_active,
|
||||
"config": endpoint.config,
|
||||
"proxy": masked_proxy_value(endpoint.proxy.as_ref()),
|
||||
"format_acceptance_config": endpoint.format_acceptance_config,
|
||||
"config": admin_secret_safe_json(endpoint.config.as_ref()),
|
||||
"proxy": admin_secret_safe_proxy(endpoint.proxy.as_ref()),
|
||||
"format_acceptance_config": admin_secret_safe_json(endpoint.format_acceptance_config.as_ref()),
|
||||
"total_keys": total_keys,
|
||||
"active_keys": active_keys,
|
||||
"created_at": endpoint_timestamp_or_now(endpoint.created_at_unix_ms, now_unix_secs),
|
||||
@@ -342,7 +334,8 @@ where
|
||||
"base_url 必须是字符串".to_string()
|
||||
});
|
||||
};
|
||||
updated.base_url = base_url.to_string();
|
||||
updated.base_url =
|
||||
admin_restore_secret_safe_url(Some(&existing_endpoint.base_url), base_url);
|
||||
}
|
||||
|
||||
if contains_field("custom_path") {
|
||||
@@ -359,7 +352,10 @@ where
|
||||
if !header_rules.is_array() {
|
||||
return Err("header_rules 必须是数组或 null".to_string());
|
||||
}
|
||||
Some(header_rules.clone())
|
||||
Some(admin_restore_secret_safe_header_rules(
|
||||
existing_endpoint.header_rules.as_ref(),
|
||||
header_rules,
|
||||
))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -373,7 +369,10 @@ where
|
||||
if !body_rules.is_array() {
|
||||
return Err("body_rules 必须是数组或 null".to_string());
|
||||
}
|
||||
Some(body_rules.clone())
|
||||
Some(admin_restore_secret_safe_body_rules(
|
||||
existing_endpoint.body_rules.as_ref(),
|
||||
body_rules,
|
||||
))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -408,7 +407,10 @@ where
|
||||
if !config.is_object() {
|
||||
return Err("config 必须是对象或 null".to_string());
|
||||
}
|
||||
Some(config.clone())
|
||||
Some(admin_restore_secret_safe_json(
|
||||
existing_endpoint.config.as_ref(),
|
||||
config,
|
||||
))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -416,26 +418,14 @@ where
|
||||
if is_null_field("proxy") {
|
||||
updated.proxy = None;
|
||||
} else {
|
||||
let Some(mut proxy) = payload
|
||||
.proxy
|
||||
.clone()
|
||||
.and_then(|value| value.as_object().cloned())
|
||||
else {
|
||||
let Some(proxy) = payload.proxy.as_ref().and_then(Value::as_object) else {
|
||||
return Err("proxy 必须是对象或 null".to_string());
|
||||
};
|
||||
if !proxy.contains_key("password") {
|
||||
if let Some(old_password) = existing_endpoint
|
||||
.proxy
|
||||
.as_ref()
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|proxy| proxy.get("password"))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
proxy.insert("password".to_string(), json!(old_password));
|
||||
}
|
||||
}
|
||||
updated.proxy = Some(Value::Object(proxy));
|
||||
let restored = admin_restore_secret_safe_proxy(
|
||||
existing_endpoint.proxy.as_ref(),
|
||||
&Value::Object(proxy.clone()),
|
||||
);
|
||||
updated.proxy = Some(restored);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,7 +439,10 @@ where
|
||||
if !config.is_object() {
|
||||
return Err("format_acceptance_config 必须是对象或 null".to_string());
|
||||
}
|
||||
Some(config.clone())
|
||||
Some(admin_restore_secret_safe_json(
|
||||
existing_endpoint.format_acceptance_config.as_ref(),
|
||||
config,
|
||||
))
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,5 +5,6 @@ pub mod oauth;
|
||||
pub mod ops;
|
||||
pub mod pool;
|
||||
pub mod quota;
|
||||
pub mod redaction;
|
||||
pub mod state;
|
||||
pub mod status;
|
||||
|
||||
@@ -53,11 +53,7 @@ pub fn parse_sub2api_balance_payload(
|
||||
return Err("响应格式无效".to_string());
|
||||
};
|
||||
if me_payload.get("code").and_then(Value::as_i64).unwrap_or(-1) != 0 {
|
||||
return Err(me_payload
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("查询用户信息失败")
|
||||
.to_string());
|
||||
return Err("查询用户信息失败".to_string());
|
||||
}
|
||||
let Some(me_data) = me_payload.get("data").and_then(Value::as_object) else {
|
||||
return Err("响应格式无效".to_string());
|
||||
@@ -126,11 +122,12 @@ pub fn attach_balance_checkin_outcome(
|
||||
.entry("extra".to_string())
|
||||
.or_insert_with(|| Value::Object(Map::new()));
|
||||
if let Some(extra) = extra.as_object_mut() {
|
||||
let message = stable_checkin_outcome_message(outcome);
|
||||
if outcome.cookie_expired {
|
||||
extra.insert("cookie_expired".to_string(), Value::Bool(true));
|
||||
extra.insert(
|
||||
"cookie_expired_message".to_string(),
|
||||
Value::String(outcome.message.clone()),
|
||||
Value::String(message.to_string()),
|
||||
);
|
||||
} else {
|
||||
extra.insert(
|
||||
@@ -139,7 +136,7 @@ pub fn attach_balance_checkin_outcome(
|
||||
);
|
||||
extra.insert(
|
||||
"checkin_message".to_string(),
|
||||
Value::String(outcome.message.clone()),
|
||||
Value::String(message.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -151,6 +148,17 @@ pub fn attach_balance_checkin_outcome(
|
||||
}
|
||||
}
|
||||
|
||||
fn stable_checkin_outcome_message(outcome: &ProviderOpsCheckinOutcome) -> &'static str {
|
||||
if outcome.cookie_expired {
|
||||
return "Cookie 已失效";
|
||||
}
|
||||
match outcome.success {
|
||||
Some(true) => "签到成功",
|
||||
Some(false) => "签到失败",
|
||||
None => "今日已签到",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_balance_data(
|
||||
total_granted: Option<f64>,
|
||||
total_used: Option<f64>,
|
||||
@@ -177,11 +185,7 @@ fn parse_new_api_balance_payload(
|
||||
{
|
||||
response_json.get("data")
|
||||
} else if response_json.get("success").and_then(Value::as_bool) == Some(false) {
|
||||
return Err(response_json
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("业务状态码表示失败")
|
||||
.to_string());
|
||||
return Err("业务状态码表示失败".to_string());
|
||||
} else {
|
||||
Some(response_json)
|
||||
};
|
||||
@@ -265,11 +269,7 @@ fn parse_cubence_balance_payload(
|
||||
{
|
||||
response_json.get("data")
|
||||
} else if response_json.get("success").and_then(Value::as_bool) == Some(false) {
|
||||
return Err(response_json
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("查询余额失败")
|
||||
.to_string());
|
||||
return Err("查询余额失败".to_string());
|
||||
} else {
|
||||
Some(response_json)
|
||||
};
|
||||
@@ -694,4 +694,47 @@ mod tests {
|
||||
assert_eq!(payload["status"], json!("auth_expired"));
|
||||
assert_eq!(payload["data"]["extra"]["cookie_expired"], json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_balance_checkin_outcome_does_not_copy_upstream_message() {
|
||||
let mut payload = json!({
|
||||
"status": "success",
|
||||
"data": { "extra": {} }
|
||||
});
|
||||
attach_balance_checkin_outcome(
|
||||
&mut payload,
|
||||
&ProviderOpsCheckinOutcome {
|
||||
success: Some(true),
|
||||
message: "authorization=Bearer upstream-secret".to_string(),
|
||||
cookie_expired: false,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
payload["data"]["extra"]["checkin_message"],
|
||||
json!("签到成功")
|
||||
);
|
||||
assert!(!payload.to_string().contains("upstream-secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balance_parsers_do_not_return_upstream_error_messages() {
|
||||
let config = json!({}).as_object().cloned().expect("config");
|
||||
let secret = "authorization=Bearer upstream-secret";
|
||||
|
||||
let generic_error = parse_query_balance_payload(
|
||||
"generic_api",
|
||||
&config,
|
||||
&json!({"success": false, "message": secret}),
|
||||
)
|
||||
.expect_err("generic API failure should be rejected");
|
||||
let sub2api_error =
|
||||
parse_sub2api_balance_payload(&config, &json!({"code": 401, "message": secret}), None)
|
||||
.expect_err("Sub2API failure should be rejected");
|
||||
|
||||
assert_eq!(generic_error, "业务状态码表示失败");
|
||||
assert_eq!(sub2api_error, "查询用户信息失败");
|
||||
assert!(!generic_error.contains("upstream-secret"));
|
||||
assert!(!sub2api_error.contains("upstream-secret"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
const ADMIN_PROVIDER_OPS_ANYROUTER_XOR_KEY: &str = "3000176000856006061501533003690027800375";
|
||||
const ADMIN_PROVIDER_OPS_ANYROUTER_SESSION_PART_MAX_BYTES: usize = 256 * 1024;
|
||||
const ADMIN_PROVIDER_OPS_ANYROUTER_UNSBOX_TABLE: [usize; 40] = [
|
||||
0xF, 0x23, 0x1D, 0x18, 0x21, 0x10, 0x1, 0x26, 0xA, 0x9, 0x13, 0x1F, 0x28, 0x1B, 0x16, 0x17,
|
||||
0x19, 0xD, 0x6, 0xB, 0x27, 0x12, 0x14, 0x8, 0xE, 0x15, 0x20, 0x1A, 0x2, 0x1E, 0x7, 0x4, 0x11,
|
||||
@@ -186,7 +187,16 @@ pub fn admin_provider_ops_anyrouter_parse_session_user_id(cookie_input: &str) ->
|
||||
}
|
||||
|
||||
fn decode_python_urlsafe_b64(input: &str) -> Option<Vec<u8>> {
|
||||
let normalized = input.trim().replace('-', "+").replace('_', "/");
|
||||
let input = input.trim();
|
||||
let max_encoded_len = ADMIN_PROVIDER_OPS_ANYROUTER_SESSION_PART_MAX_BYTES
|
||||
.saturating_add(2)
|
||||
.checked_div(3)
|
||||
.unwrap_or(usize::MAX)
|
||||
.saturating_mul(4);
|
||||
if input.is_empty() || input.len() > max_encoded_len {
|
||||
return None;
|
||||
}
|
||||
let normalized = input.replace('-', "+").replace('_', "/");
|
||||
if normalized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -195,7 +205,8 @@ fn decode_python_urlsafe_b64(input: &str) -> Option<Vec<u8>> {
|
||||
if remainder != 0 {
|
||||
padded.push_str(&"=".repeat(4 - remainder));
|
||||
}
|
||||
STANDARD.decode(padded.as_bytes()).ok()
|
||||
let decoded = STANDARD.decode(padded.as_bytes()).ok()?;
|
||||
(decoded.len() <= ADMIN_PROVIDER_OPS_ANYROUTER_SESSION_PART_MAX_BYTES).then_some(decoded)
|
||||
}
|
||||
|
||||
pub fn admin_provider_ops_verify_failure(message: impl Into<String>) -> Value {
|
||||
@@ -618,12 +629,7 @@ fn verify_payload_with_auth_messages(
|
||||
{
|
||||
response_json.get("data")
|
||||
} else if response_json.get("success").and_then(Value::as_bool) == Some(false) {
|
||||
return admin_provider_ops_verify_failure(
|
||||
response_json
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("验证失败"),
|
||||
);
|
||||
return admin_provider_ops_verify_failure("验证失败");
|
||||
} else {
|
||||
Some(response_json)
|
||||
};
|
||||
@@ -632,17 +638,6 @@ fn verify_payload_with_auth_messages(
|
||||
return admin_provider_ops_verify_failure("响应格式无效");
|
||||
};
|
||||
|
||||
let mut extra = Map::new();
|
||||
for (key, value) in user_data {
|
||||
if matches!(
|
||||
key.as_str(),
|
||||
"username" | "display_name" | "email" | "quota" | "used_quota" | "request_count"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
extra.insert(key.clone(), value.clone());
|
||||
}
|
||||
|
||||
admin_provider_ops_verify_success(
|
||||
admin_provider_ops_verify_user_payload_with_usage(
|
||||
user_data
|
||||
@@ -660,7 +655,7 @@ fn verify_payload_with_auth_messages(
|
||||
admin_provider_ops_value_as_f64(user_data.get("quota")),
|
||||
admin_provider_ops_value_as_f64(user_data.get("used_quota")),
|
||||
admin_provider_ops_value_as_u64(user_data.get("request_count")),
|
||||
Some(extra),
|
||||
None,
|
||||
),
|
||||
None,
|
||||
)
|
||||
@@ -685,12 +680,7 @@ pub fn admin_provider_ops_cubence_verify_payload(
|
||||
{
|
||||
response_json.get("data")
|
||||
} else if response_json.get("success").and_then(Value::as_bool) == Some(false) {
|
||||
return admin_provider_ops_verify_failure(
|
||||
response_json
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("验证失败"),
|
||||
);
|
||||
return admin_provider_ops_verify_failure("验证失败");
|
||||
} else {
|
||||
Some(response_json)
|
||||
};
|
||||
@@ -864,12 +854,7 @@ pub fn admin_provider_ops_sub2api_verify_payload(
|
||||
return admin_provider_ops_verify_failure("响应格式无效");
|
||||
};
|
||||
if payload.get("code").and_then(Value::as_i64).unwrap_or(-1) != 0 {
|
||||
return admin_provider_ops_verify_failure(
|
||||
payload
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("验证失败"),
|
||||
);
|
||||
return admin_provider_ops_verify_failure("验证失败");
|
||||
}
|
||||
|
||||
let Some(user_data) = payload.get("data").and_then(Value::as_object) else {
|
||||
@@ -915,9 +900,10 @@ mod tests {
|
||||
admin_provider_ops_anyrouter_compute_acw_sc_v2,
|
||||
admin_provider_ops_anyrouter_parse_session_user_id,
|
||||
admin_provider_ops_anyrouter_verify_payload, admin_provider_ops_cubence_verify_payload,
|
||||
admin_provider_ops_frontend_updated_credentials, admin_provider_ops_sub2api_verify_payload,
|
||||
admin_provider_ops_usage_api_verify_payload, admin_provider_ops_verify_headers,
|
||||
parse_verify_payload, ADMIN_PROVIDER_OPS_USER_AGENT,
|
||||
admin_provider_ops_frontend_updated_credentials, admin_provider_ops_generic_verify_payload,
|
||||
admin_provider_ops_sub2api_verify_payload, admin_provider_ops_usage_api_verify_payload,
|
||||
admin_provider_ops_verify_headers, parse_verify_payload,
|
||||
ADMIN_PROVIDER_OPS_ANYROUTER_SESSION_PART_MAX_BYTES, ADMIN_PROVIDER_OPS_USER_AGENT,
|
||||
};
|
||||
use http::StatusCode;
|
||||
use reqwest::header::COOKIE;
|
||||
@@ -951,6 +937,17 @@ mod tests {
|
||||
assert_eq!(actual.as_deref(), Some("42"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anyrouter_parse_session_user_id_rejects_oversized_encoded_cookie() {
|
||||
let encoded_limit = ADMIN_PROVIDER_OPS_ANYROUTER_SESSION_PART_MAX_BYTES
|
||||
.saturating_add(2)
|
||||
.checked_div(3)
|
||||
.unwrap()
|
||||
.saturating_mul(4);
|
||||
let cookie = format!("session={}", "A".repeat(encoded_limit + 1));
|
||||
assert!(admin_provider_ops_anyrouter_parse_session_user_id(&cookie).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontend_updated_credentials_omits_internal_runtime_fields() {
|
||||
let filtered = admin_provider_ops_frontend_updated_credentials(Map::from_iter([
|
||||
@@ -1102,6 +1099,58 @@ mod tests {
|
||||
assert_eq!(auth_failed["message"], json!("Cookie 已失效,请重新配置"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_verify_payload_does_not_reflect_upstream_errors_or_unknown_fields() {
|
||||
let failed = admin_provider_ops_generic_verify_payload(
|
||||
StatusCode::OK,
|
||||
&json!({
|
||||
"success": false,
|
||||
"message": "token=upstream-secret https://user:[email protected]/path?q=secret"
|
||||
}),
|
||||
);
|
||||
assert_eq!(failed["success"], json!(false));
|
||||
assert_eq!(failed["message"], json!("验证失败"));
|
||||
|
||||
let succeeded = admin_provider_ops_generic_verify_payload(
|
||||
StatusCode::OK,
|
||||
&json!({
|
||||
"username": "alice",
|
||||
"quota": 12.5,
|
||||
"access_token": "upstream-secret",
|
||||
"profile": {"private_note": "sensitive"}
|
||||
}),
|
||||
);
|
||||
assert_eq!(succeeded["success"], json!(true));
|
||||
assert_eq!(succeeded["data"]["username"], json!("alice"));
|
||||
assert_eq!(succeeded["data"]["quota"], json!(12.5));
|
||||
assert_eq!(succeeded["data"]["extra"], json!({}));
|
||||
let serialized = succeeded.to_string();
|
||||
assert!(!serialized.contains("upstream-secret"));
|
||||
assert!(!serialized.contains("private_note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn architecture_verify_failures_do_not_reflect_upstream_messages() {
|
||||
let cubence = admin_provider_ops_cubence_verify_payload(
|
||||
StatusCode::OK,
|
||||
&json!({
|
||||
"success": false,
|
||||
"message": "authorization=Bearer upstream-secret"
|
||||
}),
|
||||
);
|
||||
assert_eq!(cubence["message"], json!("验证失败"));
|
||||
|
||||
let sub2api = admin_provider_ops_sub2api_verify_payload(
|
||||
StatusCode::OK,
|
||||
&json!({
|
||||
"code": 500,
|
||||
"message": "cookie=session-secret"
|
||||
}),
|
||||
None,
|
||||
);
|
||||
assert_eq!(sub2api["message"], json!("验证失败"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn done_hub_verify_payload_reads_wrapped_profile() {
|
||||
let payload = parse_verify_payload(
|
||||
|
||||
@@ -6,6 +6,9 @@ use chrono::{TimeZone, Utc};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use super::redaction::{
|
||||
admin_provider_status_snapshot_safe_json, admin_secret_safe_json, admin_secret_safe_proxy,
|
||||
};
|
||||
use super::status as provider_status;
|
||||
|
||||
#[derive(Debug, Default, Clone, serde::Deserialize)]
|
||||
@@ -18,7 +21,7 @@ pub struct AdminPoolResolveSelectionRequest {
|
||||
pub quick_selectors: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, serde::Deserialize)]
|
||||
#[derive(Default, Clone, serde::Deserialize)]
|
||||
pub struct AdminPoolBatchActionRequest {
|
||||
#[serde(default)]
|
||||
pub key_ids: Vec<String>,
|
||||
@@ -28,6 +31,17 @@ pub struct AdminPoolBatchActionRequest {
|
||||
pub payload: Option<Value>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AdminPoolBatchActionRequest {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("AdminPoolBatchActionRequest")
|
||||
.field("key_ids", &self.key_ids)
|
||||
.field("action", &self.action)
|
||||
.field("payload", &self.payload.as_ref().map(|_| "[REDACTED]"))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AdminPoolBatchActionKind {
|
||||
Enable,
|
||||
@@ -39,7 +53,7 @@ pub enum AdminPoolBatchActionKind {
|
||||
Delete,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct AdminPoolBatchActionPlan {
|
||||
pub key_ids: Vec<String>,
|
||||
pub action: AdminPoolBatchActionKind,
|
||||
@@ -48,6 +62,25 @@ pub struct AdminPoolBatchActionPlan {
|
||||
pub settings_payload: Option<Value>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AdminPoolBatchActionPlan {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("AdminPoolBatchActionPlan")
|
||||
.field("key_ids", &self.key_ids)
|
||||
.field("action", &self.action)
|
||||
.field("action_label", &self.action_label)
|
||||
.field(
|
||||
"proxy_payload",
|
||||
&self.proxy_payload.as_ref().map(|_| "[REDACTED]"),
|
||||
)
|
||||
.field(
|
||||
"settings_payload",
|
||||
&self.settings_payload.as_ref().map(|_| "[REDACTED]"),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AdminPoolKeyPayloadContext {
|
||||
pub cooldown_reason: Option<String>,
|
||||
@@ -58,7 +91,7 @@ pub struct AdminPoolKeyPayloadContext {
|
||||
pub cost_limit: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, serde::Deserialize)]
|
||||
#[derive(Default, Clone, serde::Deserialize)]
|
||||
pub struct AdminPoolBatchImportRequest {
|
||||
#[serde(default)]
|
||||
pub keys: Vec<AdminPoolBatchImportItem>,
|
||||
@@ -70,7 +103,19 @@ pub struct AdminPoolBatchImportRequest {
|
||||
pub settings: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, serde::Deserialize)]
|
||||
impl std::fmt::Debug for AdminPoolBatchImportRequest {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("AdminPoolBatchImportRequest")
|
||||
.field("keys", &self.keys)
|
||||
.field("proxy_node_id", &self.proxy_node_id)
|
||||
.field("api_formats", &self.api_formats)
|
||||
.field("settings", &self.settings.as_ref().map(|_| "[REDACTED]"))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, serde::Deserialize)]
|
||||
pub struct AdminPoolBatchImportItem {
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
@@ -84,6 +129,19 @@ pub struct AdminPoolBatchImportItem {
|
||||
pub settings: Option<Value>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AdminPoolBatchImportItem {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("AdminPoolBatchImportItem")
|
||||
.field("name", &self.name)
|
||||
.field("api_key", &"[REDACTED]")
|
||||
.field("auth_type", &self.auth_type)
|
||||
.field("api_formats", &self.api_formats)
|
||||
.field("settings", &self.settings.as_ref().map(|_| "[REDACTED]"))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_pool_reason_indicates_ban(reason: &str) -> bool {
|
||||
let normalized = reason.trim().to_ascii_lowercase();
|
||||
!normalized.is_empty()
|
||||
@@ -178,6 +236,14 @@ fn admin_pool_json_object(value: Option<&Value>) -> Option<serde_json::Map<Strin
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn admin_pool_secret_safe_json_object(value: Option<&Value>) -> Value {
|
||||
admin_pool_json_object(value)
|
||||
.map(Value::Object)
|
||||
.as_ref()
|
||||
.map(|value| admin_secret_safe_json(Some(value)))
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
fn admin_pool_health_score(key: &StoredProviderCatalogKey) -> f64 {
|
||||
let scores = key
|
||||
.health_by_format
|
||||
@@ -680,7 +746,8 @@ mod tests {
|
||||
apply_admin_pool_key_settings, build_admin_pool_batch_action_plan,
|
||||
build_admin_pool_batch_import_key_record, build_admin_pool_key_payload,
|
||||
resolve_admin_pool_key_settings, validate_admin_pool_key_settings_payload,
|
||||
AdminPoolBatchActionKind, AdminPoolBatchActionRequest, AdminPoolKeyPayloadContext,
|
||||
AdminPoolBatchActionKind, AdminPoolBatchActionRequest, AdminPoolBatchImportItem,
|
||||
AdminPoolBatchImportRequest, AdminPoolKeyPayloadContext,
|
||||
};
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
|
||||
use serde_json::json;
|
||||
@@ -699,6 +766,31 @@ mod tests {
|
||||
key
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_pool_import_debug_output_redacts_api_keys_and_settings() {
|
||||
let request = AdminPoolBatchImportRequest {
|
||||
keys: vec![AdminPoolBatchImportItem {
|
||||
name: "key".to_string(),
|
||||
api_key: "pool-api-key-canary".to_string(),
|
||||
auth_type: "api_key".to_string(),
|
||||
api_formats: vec!["openai:chat".to_string()],
|
||||
settings: Some(json!({"credential": "item-settings-canary"})),
|
||||
}],
|
||||
proxy_node_id: None,
|
||||
api_formats: Vec::new(),
|
||||
settings: Some(json!({"password": "request-settings-canary"})),
|
||||
};
|
||||
let debug = format!("{request:?}");
|
||||
assert!(debug.contains("[REDACTED]"));
|
||||
for secret in [
|
||||
"pool-api-key-canary",
|
||||
"item-settings-canary",
|
||||
"request-settings-canary",
|
||||
] {
|
||||
assert!(!debug.contains(secret), "debug output leaked {secret}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_codex_exhaustion_from_metadata() {
|
||||
assert!(admin_pool_key_account_quota_exhausted(
|
||||
@@ -887,6 +979,52 @@ mod tests {
|
||||
assert_eq!(payload["scheduling_label"], json!("可用"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_pool_payload_projects_historical_status_and_cooldown_diagnostics() {
|
||||
let mut key = sample_key(None);
|
||||
key.status_snapshot = Some(json!({
|
||||
"oauth": {
|
||||
"code": "invalid",
|
||||
"reason": "Authorization: Bearer upstream-secret"
|
||||
},
|
||||
"account": {
|
||||
"code": "account_disabled",
|
||||
"blocked": true,
|
||||
"reason": "https://user:[email protected]?q=secret"
|
||||
},
|
||||
"quota": {
|
||||
"code": "cooldown",
|
||||
"exhausted": false,
|
||||
"reason": "Authorization: Bearer upstream-secret",
|
||||
"reset_credits": {
|
||||
"detail_error": "https://user:[email protected]?q=secret"
|
||||
}
|
||||
}
|
||||
}));
|
||||
let context = AdminPoolKeyPayloadContext {
|
||||
cooldown_reason: Some(
|
||||
"Authorization: Bearer upstream-secret https://user:[email protected]?q=secret"
|
||||
.to_string(),
|
||||
),
|
||||
..AdminPoolKeyPayloadContext::default()
|
||||
};
|
||||
|
||||
let payload = build_admin_pool_key_payload(&key, &context);
|
||||
|
||||
assert_eq!(
|
||||
payload["cooldown_reason"],
|
||||
json!("Provider key is cooling down")
|
||||
);
|
||||
assert_eq!(
|
||||
payload.pointer("/status_snapshot/oauth/reason"),
|
||||
Some(&json!("OAuth token is invalid"))
|
||||
);
|
||||
let serialized = payload.to_string();
|
||||
assert!(!serialized.contains("upstream-secret"));
|
||||
assert!(!serialized.contains("user:password"));
|
||||
assert!(!serialized.contains("q=secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_and_applies_shared_key_settings() {
|
||||
let settings = json!({
|
||||
@@ -996,10 +1134,14 @@ pub fn build_admin_pool_key_payload(
|
||||
) -> Value {
|
||||
let health_score = admin_pool_health_score(key);
|
||||
let circuit_breaker_open = false;
|
||||
let cooldown_reason = context
|
||||
.cooldown_reason
|
||||
.as_ref()
|
||||
.map(|_| "Provider key is cooling down".to_string());
|
||||
let (scheduling_status, scheduling_reason, scheduling_label, scheduling_reasons) =
|
||||
admin_pool_scheduling_payload(
|
||||
key,
|
||||
context.cooldown_reason.as_deref(),
|
||||
cooldown_reason.as_deref(),
|
||||
context.cooldown_ttl_seconds,
|
||||
);
|
||||
|
||||
@@ -1008,25 +1150,29 @@ pub fn build_admin_pool_key_payload(
|
||||
"key_name": key.name,
|
||||
"is_active": key.is_active,
|
||||
"auth_type": key.auth_type,
|
||||
"status_snapshot": key.status_snapshot.clone().unwrap_or_else(|| json!({})),
|
||||
"status_snapshot": key
|
||||
.status_snapshot
|
||||
.as_ref()
|
||||
.map(|value| admin_provider_status_snapshot_safe_json(Some(value)))
|
||||
.unwrap_or_else(|| json!({})),
|
||||
"health_score": health_score,
|
||||
"circuit_breaker_open": circuit_breaker_open,
|
||||
"api_formats": admin_pool_api_formats(key),
|
||||
"rate_multipliers": admin_pool_json_object(key.rate_multipliers.as_ref()),
|
||||
"rate_multipliers": admin_pool_secret_safe_json_object(key.rate_multipliers.as_ref()),
|
||||
"internal_priority": key.internal_priority,
|
||||
"rpm_limit": key.rpm_limit,
|
||||
"cache_ttl_minutes": key.cache_ttl_minutes,
|
||||
"max_probe_interval_minutes": key.max_probe_interval_minutes,
|
||||
"note": key.note,
|
||||
"allowed_models": admin_pool_string_list(key.allowed_models.as_ref()),
|
||||
"capabilities": admin_pool_json_object(key.capabilities.as_ref()),
|
||||
"capabilities": admin_pool_secret_safe_json_object(key.capabilities.as_ref()),
|
||||
"auto_fetch_models": key.auto_fetch_models,
|
||||
"locked_models": admin_pool_string_list(key.locked_models.as_ref()),
|
||||
"model_include_patterns": admin_pool_string_list(key.model_include_patterns.as_ref()),
|
||||
"model_exclude_patterns": admin_pool_string_list(key.model_exclude_patterns.as_ref()),
|
||||
"proxy": key.proxy.clone(),
|
||||
"fingerprint": key.fingerprint.clone(),
|
||||
"cooldown_reason": context.cooldown_reason,
|
||||
"proxy": admin_secret_safe_proxy(key.proxy.as_ref()),
|
||||
"fingerprint": admin_secret_safe_json(key.fingerprint.as_ref()),
|
||||
"cooldown_reason": cooldown_reason,
|
||||
"cooldown_ttl_seconds": context.cooldown_ttl_seconds,
|
||||
"cost_window_usage": context.cost_window_usage,
|
||||
"cost_limit": context.cost_limit,
|
||||
|
||||
@@ -3296,54 +3296,24 @@ pub fn codex_structured_invalid_reason(status_code: u16, upstream_message: Optio
|
||||
return format!("{OAUTH_ACCOUNT_BLOCK_PREFIX}工作区已停用 (deactivated_workspace)");
|
||||
}
|
||||
if codex_looks_like_account_deactivated(Some(message)) {
|
||||
let detail = if message.is_empty() {
|
||||
"OpenAI 账号已停用"
|
||||
} else {
|
||||
message
|
||||
};
|
||||
return format!("{OAUTH_ACCOUNT_BLOCK_PREFIX}{detail}");
|
||||
return format!("{OAUTH_ACCOUNT_BLOCK_PREFIX}OpenAI 账号已停用");
|
||||
}
|
||||
if codex_looks_like_token_invalidated(Some(message)) {
|
||||
let detail = if message.is_empty() {
|
||||
"Codex Token 已失效"
|
||||
} else {
|
||||
message
|
||||
};
|
||||
return format!("{OAUTH_EXPIRED_PREFIX}{detail}");
|
||||
return format!("{OAUTH_EXPIRED_PREFIX}Codex Token 已失效");
|
||||
}
|
||||
if codex_looks_like_token_expired(Some(message)) {
|
||||
let detail = if message.is_empty() {
|
||||
"Codex Token 已过期"
|
||||
} else {
|
||||
message
|
||||
};
|
||||
return format!("{OAUTH_EXPIRED_PREFIX}{detail}");
|
||||
return format!("{OAUTH_EXPIRED_PREFIX}Codex Token 已过期");
|
||||
}
|
||||
if status_code == 401 {
|
||||
let detail = if message.is_empty() {
|
||||
"Codex Token 已过期 (401)"
|
||||
} else {
|
||||
message
|
||||
};
|
||||
return format!("{OAUTH_EXPIRED_PREFIX}{detail}");
|
||||
return format!("{OAUTH_EXPIRED_PREFIX}Codex Token 已过期 (401)");
|
||||
}
|
||||
if status_code == 403 {
|
||||
let detail = if message.is_empty() {
|
||||
"Codex 账户访问受限 (403)"
|
||||
} else {
|
||||
message
|
||||
};
|
||||
return format!("{OAUTH_ACCOUNT_BLOCK_PREFIX}{detail}");
|
||||
return format!("{OAUTH_ACCOUNT_BLOCK_PREFIX}Codex 账户访问受限 (403)");
|
||||
}
|
||||
if status_code == 402 {
|
||||
let detail = if message.is_empty() {
|
||||
"Codex 账户需要付款 (402)"
|
||||
} else {
|
||||
message
|
||||
};
|
||||
return format!("{OAUTH_ACCOUNT_BLOCK_PREFIX}{detail}");
|
||||
return format!("{OAUTH_ACCOUNT_BLOCK_PREFIX}Codex 账户需要付款 (402)");
|
||||
}
|
||||
message.to_string()
|
||||
format!("Codex 请求失败 ({status_code})")
|
||||
}
|
||||
|
||||
pub fn codex_runtime_invalid_reason(
|
||||
@@ -3367,11 +3337,8 @@ pub fn codex_runtime_invalid_reason(
|
||||
}
|
||||
|
||||
fn codex_generic_forbidden_runtime_invalid_reason(upstream_message: Option<&str>) -> String {
|
||||
let detail = upstream_message
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|message| format!("Codex Token 已失效 (403): {message}"))
|
||||
.unwrap_or_else(|| "Codex Token 已失效 (403)".to_string());
|
||||
let _ = upstream_message;
|
||||
let detail = "Codex Token 已失效 (403)";
|
||||
format!("{OAUTH_EXPIRED_PREFIX}{detail}")
|
||||
}
|
||||
|
||||
@@ -3379,11 +3346,8 @@ pub fn codex_soft_request_failure_reason(
|
||||
status_code: u16,
|
||||
upstream_message: Option<&str>,
|
||||
) -> String {
|
||||
let detail = upstream_message
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| format!("Codex 请求失败 ({status_code})"));
|
||||
let _ = upstream_message;
|
||||
let detail = format!("Codex 请求失败 ({status_code})");
|
||||
format!("{OAUTH_REQUEST_FAILED_PREFIX}{detail}")
|
||||
}
|
||||
|
||||
@@ -3696,33 +3660,21 @@ pub fn parse_windsurf_user_status_response(
|
||||
result.insert(target.to_string(), json!(found));
|
||||
}
|
||||
}
|
||||
for (target, aliases) in [
|
||||
(
|
||||
"ban_reason",
|
||||
&[
|
||||
"banReason",
|
||||
"ban_reason",
|
||||
"blockedReason",
|
||||
"blocked_reason",
|
||||
"reason",
|
||||
"message",
|
||||
][..],
|
||||
),
|
||||
for (status_field, reason_field, fixed_reason) in [
|
||||
("banned", "ban_reason", "Windsurf account is suspended"),
|
||||
(
|
||||
"quarantined",
|
||||
"quarantine_reason",
|
||||
&["quarantineReason", "quarantine_reason", "reason", "message"][..],
|
||||
"Windsurf account is quarantined",
|
||||
),
|
||||
(
|
||||
"is_forbidden",
|
||||
"forbidden_reason",
|
||||
&["forbiddenReason", "forbidden_reason", "reason", "message"][..],
|
||||
"Windsurf account access is restricted",
|
||||
),
|
||||
] {
|
||||
if let Some(found) = status_sources.iter().find_map(|source| {
|
||||
aliases
|
||||
.iter()
|
||||
.find_map(|alias| coerce_json_string(source.get(*alias)))
|
||||
}) {
|
||||
result.insert(target.to_string(), json!(found));
|
||||
if result.get(status_field).and_then(coerce_json_bool) == Some(true) {
|
||||
result.insert(reason_field.to_string(), json!(fixed_reason));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3908,19 +3860,16 @@ fn normalize_chatgpt_web_numeric_reset(value: f64, observed_at: u64) -> Option<u
|
||||
}
|
||||
|
||||
fn chatgpt_web_blocked_features(value: &serde_json::Value) -> Vec<String> {
|
||||
value
|
||||
let image_blocked = value
|
||||
.get("blocked_features")
|
||||
.or_else(|| value.get("blockedFeatures"))
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.any(chatgpt_web_is_image_quota_feature);
|
||||
image_blocked
|
||||
.then(|| vec!["image_generation".to_string()])
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -3971,11 +3920,9 @@ pub fn parse_chatgpt_web_conversation_init_response(
|
||||
json!(plan_type.to_ascii_lowercase()),
|
||||
);
|
||||
}
|
||||
result.insert("blocked_features".to_string(), json!(blocked_features));
|
||||
result.insert(
|
||||
"limits_progress".to_string(),
|
||||
serde_json::Value::Array(limits_progress),
|
||||
);
|
||||
if !blocked_features.is_empty() {
|
||||
result.insert("blocked_features".to_string(), json!(blocked_features));
|
||||
}
|
||||
|
||||
if image_blocked {
|
||||
result.insert("image_quota_blocked".to_string(), json!(true));
|
||||
@@ -4048,13 +3995,6 @@ pub fn parse_chatgpt_web_conversation_init_response(
|
||||
if let Some(reset_at) = reset_at {
|
||||
result.insert("image_quota_reset_at".to_string(), json!(reset_at));
|
||||
}
|
||||
if let Some(reset_after) = coerce_json_string(
|
||||
image_limit
|
||||
.get("reset_after")
|
||||
.or_else(|| image_limit.get("resetAfter")),
|
||||
) {
|
||||
result.insert("image_quota_reset_after".to_string(), json!(reset_after));
|
||||
}
|
||||
} else if image_blocked {
|
||||
result.insert("image_quota_remaining".to_string(), json!(0.0));
|
||||
}
|
||||
@@ -5796,7 +5736,7 @@ mod tests {
|
||||
fn codex_runtime_invalid_reason_marks_401_as_expired() {
|
||||
assert_eq!(
|
||||
codex_runtime_invalid_reason(401, Some("session expired")),
|
||||
Some(format!("{OAUTH_EXPIRED_PREFIX}session expired"))
|
||||
Some(format!("{OAUTH_EXPIRED_PREFIX}Codex Token 已过期"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5804,9 +5744,7 @@ mod tests {
|
||||
fn codex_runtime_invalid_reason_marks_account_deactivated_403() {
|
||||
assert_eq!(
|
||||
codex_runtime_invalid_reason(403, Some("account has been deactivated")),
|
||||
Some(format!(
|
||||
"{OAUTH_ACCOUNT_BLOCK_PREFIX}account has been deactivated"
|
||||
))
|
||||
Some(format!("{OAUTH_ACCOUNT_BLOCK_PREFIX}OpenAI 账号已停用"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5814,18 +5752,14 @@ mod tests {
|
||||
fn codex_runtime_invalid_reason_marks_inactive_pat_owner_403_as_token_invalid() {
|
||||
assert_eq!(
|
||||
codex_runtime_invalid_reason(403, Some("Personal access token owner is inactive.")),
|
||||
Some(format!(
|
||||
"{OAUTH_EXPIRED_PREFIX}Personal access token owner is inactive."
|
||||
))
|
||||
Some(format!("{OAUTH_EXPIRED_PREFIX}Codex Token 已失效"))
|
||||
);
|
||||
assert_eq!(
|
||||
codex_runtime_invalid_reason(
|
||||
403,
|
||||
Some("biscuit_baker_service_auth_credential_error_status")
|
||||
),
|
||||
Some(format!(
|
||||
"{OAUTH_EXPIRED_PREFIX}biscuit_baker_service_auth_credential_error_status"
|
||||
))
|
||||
Some(format!("{OAUTH_EXPIRED_PREFIX}Codex Token 已失效"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5833,9 +5767,7 @@ mod tests {
|
||||
fn codex_runtime_invalid_reason_marks_deleted_agent_runtime_as_invalid() {
|
||||
assert_eq!(
|
||||
codex_runtime_invalid_reason(403, Some("Agent runtime has been deleted.")),
|
||||
Some(format!(
|
||||
"{OAUTH_EXPIRED_PREFIX}Agent runtime has been deleted."
|
||||
))
|
||||
Some(format!("{OAUTH_EXPIRED_PREFIX}Codex Token 已失效"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5843,7 +5775,9 @@ mod tests {
|
||||
fn codex_runtime_invalid_reason_marks_402_as_account_blocked() {
|
||||
assert_eq!(
|
||||
codex_runtime_invalid_reason(402, Some("payment required")),
|
||||
Some(format!("{OAUTH_ACCOUNT_BLOCK_PREFIX}payment required"))
|
||||
Some(format!(
|
||||
"{OAUTH_ACCOUNT_BLOCK_PREFIX}Codex 账户需要付款 (402)"
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5851,12 +5785,26 @@ mod tests {
|
||||
fn codex_runtime_invalid_reason_marks_generic_403_as_token_invalid() {
|
||||
assert_eq!(
|
||||
codex_runtime_invalid_reason(403, Some("forbidden")),
|
||||
Some(format!(
|
||||
"{OAUTH_EXPIRED_PREFIX}Codex Token 已失效 (403): forbidden"
|
||||
))
|
||||
Some(format!("{OAUTH_EXPIRED_PREFIX}Codex Token 已失效 (403)"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_invalid_reason_does_not_persist_upstream_credentials() {
|
||||
let reason = codex_runtime_invalid_reason(
|
||||
401,
|
||||
Some("authorization=Bearer upstream-secret https://user:[email protected]?q=secret"),
|
||||
)
|
||||
.expect("401 should produce a reason");
|
||||
|
||||
assert_eq!(
|
||||
reason,
|
||||
format!("{OAUTH_EXPIRED_PREFIX}Codex Token 已过期 (401)")
|
||||
);
|
||||
assert!(!reason.contains("upstream-secret"));
|
||||
assert!(!reason.contains("user:pass"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_invalid_state_appends_refresh_failure_to_oauth_expired() {
|
||||
let mut key = StoredProviderCatalogKey::new(
|
||||
@@ -7232,11 +7180,36 @@ mod tests {
|
||||
assert_eq!(parsed.get("quarantined"), Some(&json!(true)));
|
||||
assert_eq!(
|
||||
parsed.get("quarantine_reason"),
|
||||
Some(&json!("quota review"))
|
||||
Some(&json!("Windsurf account is quarantined"))
|
||||
);
|
||||
assert_eq!(parsed.get("updated_at"), Some(&json!(1_770_000_000u64)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_status_parser_never_persists_upstream_reason_or_message() {
|
||||
let parsed = parse_windsurf_user_status_response(
|
||||
&json!({
|
||||
"userStatus": {
|
||||
"isBanned": true,
|
||||
"reason": "Authorization: Bearer upstream-secret",
|
||||
"message": "https://user:[email protected]?q=secret",
|
||||
"planStatus": {"dailyQuotaRemainingPercent": 10}
|
||||
}
|
||||
}),
|
||||
1_770_000_000,
|
||||
)
|
||||
.expect("windsurf status should parse");
|
||||
|
||||
assert_eq!(
|
||||
parsed.get("ban_reason"),
|
||||
Some(&json!("Windsurf account is suspended"))
|
||||
);
|
||||
let serialized = parsed.to_string();
|
||||
assert!(!serialized.contains("upstream-secret"));
|
||||
assert!(!serialized.contains("user:password"));
|
||||
assert!(!serialized.contains("q=secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_windsurf_model_configs_response() {
|
||||
let parsed = parse_windsurf_model_configs_response(
|
||||
@@ -7325,7 +7298,11 @@ mod tests {
|
||||
fn parses_chatgpt_web_blocked_image_feature_as_zero_remaining() {
|
||||
let parsed = parse_chatgpt_web_conversation_init_response(
|
||||
&json!({
|
||||
"blocked_features": ["image_generation"],
|
||||
"blocked_features": [
|
||||
"image_generation",
|
||||
"Authorization: Bearer upstream-secret",
|
||||
"https://user:[email protected]?q=secret"
|
||||
],
|
||||
"limits_progress": []
|
||||
}),
|
||||
1_778_067_246,
|
||||
@@ -7334,5 +7311,43 @@ mod tests {
|
||||
|
||||
assert_eq!(parsed.get("image_quota_blocked"), Some(&json!(true)));
|
||||
assert_eq!(parsed.get("image_quota_remaining"), Some(&json!(0.0)));
|
||||
assert_eq!(
|
||||
parsed.get("blocked_features"),
|
||||
Some(&json!(["image_generation"]))
|
||||
);
|
||||
assert!(parsed.get("limits_progress").is_none());
|
||||
assert!(!parsed.to_string().contains("upstream-secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chatgpt_web_parser_projects_image_limit_scalars_only() {
|
||||
let parsed = parse_chatgpt_web_conversation_init_response(
|
||||
&json!({
|
||||
"limits_progress": [{
|
||||
"feature_name": "image_gen",
|
||||
"remaining": 8,
|
||||
"total": 12,
|
||||
"reset_after": "60",
|
||||
"message": "Authorization: Bearer upstream-secret",
|
||||
"nested": {
|
||||
"url": "https://user:[email protected]?q=secret"
|
||||
}
|
||||
}]
|
||||
}),
|
||||
1_778_067_246,
|
||||
)
|
||||
.expect("image quota should parse");
|
||||
|
||||
assert_eq!(parsed.get("image_quota_remaining"), Some(&json!(8.0)));
|
||||
assert_eq!(parsed.get("image_quota_total"), Some(&json!(12.0)));
|
||||
assert_eq!(
|
||||
parsed.get("image_quota_reset_at"),
|
||||
Some(&json!(1_778_067_306u64))
|
||||
);
|
||||
assert!(parsed.get("limits_progress").is_none());
|
||||
assert!(parsed.get("image_quota_reset_after").is_none());
|
||||
let serialized = parsed.to_string();
|
||||
assert!(!serialized.contains("upstream-secret"));
|
||||
assert!(!serialized.contains("user:password"));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ use uuid::Uuid;
|
||||
|
||||
const KIRO_DEVICE_DEFAULT_START_URL: &str = "https://view.awsapps.com/start";
|
||||
const KIRO_DEVICE_DEFAULT_REGION: &str = "us-east-1";
|
||||
const MAX_UNVERIFIED_JWT_CLAIMS_BYTES: usize = 64 * 1024;
|
||||
|
||||
pub fn current_unix_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
@@ -112,7 +113,18 @@ pub fn json_u64_value(value: Option<&Value>) -> Option<u64> {
|
||||
|
||||
pub fn decode_jwt_claims(token: &str) -> Option<Map<String, Value>> {
|
||||
let payload = token.split('.').nth(1)?;
|
||||
let max_encoded_len = MAX_UNVERIFIED_JWT_CLAIMS_BYTES
|
||||
.saturating_add(2)
|
||||
.checked_div(3)
|
||||
.unwrap_or(usize::MAX)
|
||||
.saturating_mul(4);
|
||||
if payload.len() > max_encoded_len {
|
||||
return None;
|
||||
}
|
||||
let bytes = URL_SAFE_NO_PAD.decode(payload.as_bytes()).ok()?;
|
||||
if bytes.len() > MAX_UNVERIFIED_JWT_CLAIMS_BYTES {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_slice::<Value>(&bytes)
|
||||
.ok()?
|
||||
.as_object()
|
||||
@@ -398,7 +410,10 @@ pub fn build_kiro_device_key_name(email: Option<&str>, refresh_token: Option<&st
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{enrich_admin_provider_oauth_auth_config, parse_provider_oauth_callback_params};
|
||||
use super::{
|
||||
decode_jwt_claims, enrich_admin_provider_oauth_auth_config,
|
||||
parse_provider_oauth_callback_params, MAX_UNVERIFIED_JWT_CLAIMS_BYTES,
|
||||
};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -516,4 +531,16 @@ mod tests {
|
||||
assert_eq!(auth_config.get("user_id"), Some(&json!("user-image")));
|
||||
assert_eq!(auth_config.get("is_fedramp"), Some(&json!(true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_jwt_claims_rejects_oversized_payload_before_decode() {
|
||||
let max_encoded_len = MAX_UNVERIFIED_JWT_CLAIMS_BYTES
|
||||
.saturating_add(2)
|
||||
.checked_div(3)
|
||||
.unwrap()
|
||||
.saturating_mul(4);
|
||||
let token = format!("header.{}.signature", "A".repeat(max_encoded_len + 1));
|
||||
|
||||
assert_eq!(decode_jwt_claims(&token), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ const ACCOUNT_BLOCK_REASON_KEYWORDS: &[&str] = &[
|
||||
"deactivated",
|
||||
"访问被禁止",
|
||||
"账户访问被禁止",
|
||||
"账号已停用",
|
||||
"账户已停用",
|
||||
"访问受限",
|
||||
"账户访问受限",
|
||||
"oauth_token_invalid",
|
||||
@@ -229,6 +231,8 @@ fn classify_block_reason(reason: &str) -> (&'static str, &'static str) {
|
||||
"organization_disabled",
|
||||
"访问被禁止",
|
||||
"账户访问被禁止",
|
||||
"账号已停用",
|
||||
"账户已停用",
|
||||
]
|
||||
.iter()
|
||||
.any(|keyword| lowered.contains(keyword))
|
||||
@@ -868,4 +872,26 @@ mod tests {
|
||||
assert!(!should_auto_remove_account_state(&state));
|
||||
assert!(account_state_indicates_known_ban(&state));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_chinese_account_disabled_state_is_auto_removed() {
|
||||
for reason in [
|
||||
"[ACCOUNT_BLOCK] OpenAI 账号已停用",
|
||||
"[ACCOUNT_BLOCK] OpenAI 账户已停用",
|
||||
] {
|
||||
let state = resolve_pool_account_state(Some("codex"), None, Some(reason));
|
||||
|
||||
assert!(state.blocked);
|
||||
assert_eq!(state.code.as_deref(), Some("account_disabled"));
|
||||
assert!(should_auto_remove_account_state(&state));
|
||||
}
|
||||
|
||||
let verification = resolve_pool_account_state(
|
||||
Some("codex"),
|
||||
None,
|
||||
Some("[ACCOUNT_BLOCK] verify your account before continuing"),
|
||||
);
|
||||
assert_eq!(verification.code.as_deref(), Some("account_verification"));
|
||||
assert!(!should_auto_remove_account_state(&verification));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user