diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f6fc03548..34f25dab2 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,24 +1,27 @@ { "permissions": { "allow": [ - "Bash(npm run format:*)", - "Bash(npm run lint)", - "Bash(npx tsc:*)", - "Bash(cargo test:*)", - "Bash(cargo build:*)", - "Bash(npm run check:*)", - "Bash(npm run:*)", - "Bash(tree:*)", - "Bash(find:*)", - "Bash(cargo check:*)", - "Bash(rm:*)", - "Bash(cargo clippy:*)", - "Bash(cargo fmt:*)", - "Bash(lsof:*)", - "Bash(xargs kill:*)", - "Bash(cargo run:*)" + "Bash", + "Read(*)", + "Write(*)", + "Edit(*)", + "MultiEdit(*)", + "Glob(*)", + "Grep(*)", + "Task(*)", + "TaskOutput(*)", + "LSP(*)", + "NotebookEdit(*)", + "TodoWrite(*)", + "AskUserQuestion(*)", + "EnterPlanMode(*)", + "ExitPlanMode(*)", + "KillShell(*)", + "WebFetch(domain:*)", + "Skill(*)", + "SlashCommand(*)" ], "deny": [], - "ask": [] + "defaultMode": "bypassPermissions" } } diff --git a/src-tauri/src/injection/types.rs b/src-tauri/src/injection/types.rs index 300ef203e..178253c23 100644 --- a/src-tauri/src/injection/types.rs +++ b/src-tauri/src/injection/types.rs @@ -4,6 +4,30 @@ use serde::{Deserialize, Serialize}; +/// 允许注入的参数白名单 +/// 这些参数是安全的,不会影响请求的核心行为 +const ALLOWED_INJECTION_PARAMS: &[&str] = &[ + "temperature", + "max_tokens", + "top_p", + "top_k", + "frequency_penalty", + "presence_penalty", + "stop", + "seed", + "n", +]; + +/// 禁止注入的参数黑名单(即使在白名单中也不允许 Override 模式) +const BLOCKED_OVERRIDE_PARAMS: &[&str] = &[ + "model", + "messages", + "tools", + "tool_choice", + "stream", + "response_format", +]; + /// 注入模式 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] @@ -213,6 +237,20 @@ impl Injector { let mut rule_applied = false; for (key, value) in params { + // 安全修复:检查参数是否在白名单中 + if !ALLOWED_INJECTION_PARAMS.contains(&key.as_str()) { + tracing::warn!("[INJECTION] 参数 {} 不在白名单中,跳过注入", key); + continue; + } + + // 安全修复:Override 模式下检查黑名单 + if rule.mode == InjectionMode::Override + && BLOCKED_OVERRIDE_PARAMS.contains(&key.as_str()) + { + tracing::warn!("[INJECTION] 参数 {} 禁止使用 Override 模式", key); + continue; + } + let should_inject = match rule.mode { InjectionMode::Merge => !obj.contains_key(key), InjectionMode::Override => true, diff --git a/src-tauri/src/middleware/management_auth.rs b/src-tauri/src/middleware/management_auth.rs index 5ba4fdd99..ca5b4ccdc 100644 --- a/src-tauri/src/middleware/management_auth.rs +++ b/src-tauri/src/middleware/management_auth.rs @@ -30,11 +30,15 @@ use tower::{Layer, Service}; const MAX_AUTH_FAILURES: u32 = 5; const FAILURE_WINDOW_SECS: u64 = 60; const BLOCK_SECS: u64 = 300; +// 安全修复:限制 failure_map 最大条目数,防止内存 DoS +const MAX_FAILURE_ENTRIES: usize = 10000; +const ENTRY_EXPIRE_SECS: u64 = 3600; struct FailureState { count: u32, window_start: Instant, blocked_until: Option, + last_access: Instant, } fn failure_map() -> &'static Mutex> { @@ -125,16 +129,11 @@ impl ManagementAuthService { } fn get_client_id(req: &Request) -> String { + // 安全修复:只使用真实的连接地址,不信任 X-Forwarded-For + // X-Forwarded-For 可被伪造,用于绕过限速或导致 failure_map 无界增长 if let Some(addr) = Self::get_client_addr(req) { return addr.ip().to_string(); } - if let Some(forwarded) = req.headers().get("x-forwarded-for") { - if let Ok(value) = forwarded.to_str() { - if let Some(first) = value.split(',').next() { - return first.trim().to_string(); - } - } - } "unknown".to_string() } @@ -142,6 +141,7 @@ impl ManagementAuthService { let now = Instant::now(); let mut map = failure_map().lock().unwrap(); if let Some(state) = map.get_mut(client_id) { + state.last_access = now; if let Some(blocked_until) = state.blocked_until { if blocked_until > now { return false; @@ -161,11 +161,21 @@ impl ManagementAuthService { fn record_failure(client_id: &str) { let now = Instant::now(); let mut map = failure_map().lock().unwrap(); + + // 安全修复:容量保护,超过上限时清理长时间未访问的条目 + if map.len() > MAX_FAILURE_ENTRIES { + map.retain(|_, state| { + now.duration_since(state.last_access).as_secs() <= ENTRY_EXPIRE_SECS + }); + } + let entry = map.entry(client_id.to_string()).or_insert(FailureState { count: 0, window_start: now, blocked_until: None, + last_access: now, }); + entry.last_access = now; if now.duration_since(entry.window_start).as_secs() > FAILURE_WINDOW_SECS { entry.count = 0; diff --git a/src-tauri/src/providers/codex.rs b/src-tauri/src/providers/codex.rs index c3239091f..c577033b9 100644 --- a/src-tauri/src/providers/codex.rs +++ b/src-tauri/src/providers/codex.rs @@ -77,8 +77,7 @@ pub struct CodexCredentials { #[serde( default, skip_serializing_if = "Option::is_none", - rename = "expired", - alias = "expires_at", + alias = "expired", alias = "expiresAt" )] pub expires_at: Option, diff --git a/src-tauri/src/providers/kiro.rs b/src-tauri/src/providers/kiro.rs index 638f751ad..fb0150ee6 100644 --- a/src-tauri/src/providers/kiro.rs +++ b/src-tauri/src/providers/kiro.rs @@ -254,27 +254,8 @@ impl KiroProvider { tracing::info!("[KIRO] 没有 clientIdHash 字段"); } - // 读取目录中其他 JSON 文件 - if tokio::fs::try_exists(dir).await.unwrap_or(false) { - let mut entries = tokio::fs::read_dir(dir).await?; - while let Some(entry) = entries.next_entry().await? { - let file_path = entry.path(); - if file_path.extension().map(|e| e == "json").unwrap_or(false) && file_path != path - { - if let Ok(content) = tokio::fs::read_to_string(&file_path).await { - if let Ok(creds) = serde_json::from_str::(&content) { - tracing::info!( - "[KIRO] Extra file {:?}: has_client_id={}, has_client_secret={}", - file_path.file_name(), - creds.client_id.is_some(), - creds.client_secret.is_some() - ); - merge_credentials(&mut merged, &creds); - } - } - } - } - } + // 安全修复:不再遍历目录中其他 JSON 文件,避免串凭证/串账号风险 + // 只信任主凭证文件和 clientIdHash 指向的文件 tracing::info!( "[KIRO] Final merged: has_access={}, has_refresh={}, has_client_id={}, has_client_secret={}, auth_method={:?}", @@ -557,11 +538,8 @@ impl KiroProvider { token_len < 100 || refresh_token.ends_with("...") || refresh_token.contains("..."); if is_truncated { - tracing::error!( - "[KIRO] 检测到 refreshToken 被截断!长度: {}, 内容: {}...", - token_len, - &refresh_token[..std::cmp::min(30, token_len)] - ); + // 安全修复:不打印 token 内容,只打印长度 + tracing::error!("[KIRO] 检测到 refreshToken 被截断!长度: {}", token_len); return Err(format!( "refreshToken 已被截断(长度: {} 字符)。\n\n⚠️ 这通常是 Kiro IDE 为了防止凭证被第三方工具使用而故意截断的。\n\n💡 解决方案:\n1. 使用 Kir-Manager 工具获取完整的凭证\n2. 或者使用其他方式获取未截断的凭证文件\n3. 正常的 refreshToken 长度应该在 500+ 字符", token_len @@ -884,45 +862,49 @@ impl KiroProvider { let cw_request = convert_openai_to_codewhisperer(request, profile_arn); let url = self.get_base_url(); - // Debug: 记录转换后的请求 - if let Ok(json_str) = serde_json::to_string_pretty(&cw_request) { - // 保存到文件用于调试 - let uuid_prefix = uuid::Uuid::new_v4() - .to_string() - .split('-') - .next() - .unwrap_or("unknown") - .to_string(); - let debug_path = dirs::home_dir() - .unwrap_or_default() - .join(".proxycast") - .join("logs") - .join(format!("cw_request_{uuid_prefix}.json")); - let _ = tokio::fs::write(&debug_path, &json_str).await; - tracing::debug!("[CW_REQ] Request saved to {:?}", debug_path); - - // 记录历史消息数量和 tool_results 情况 - let history_len = cw_request - .conversation_state - .history - .as_ref() - .map(|h| h.len()) - .unwrap_or(0); - let current_has_tools = cw_request - .conversation_state - .current_message - .user_input_message - .user_input_message_context - .as_ref() - .map(|ctx| ctx.tool_results.as_ref().map(|tr| tr.len()).unwrap_or(0)) - .unwrap_or(0); - tracing::info!( - "[CW_REQ] history={} current_tool_results={}", - history_len, - current_has_tools - ); + // 安全修复:仅在 PROXYCAST_DEBUG=1 时写入请求调试文件,避免泄露敏感信息 + let debug_enabled = std::env::var("PROXYCAST_DEBUG") + .map(|v| v == "1") + .unwrap_or(false); + if debug_enabled { + if let Ok(json_str) = serde_json::to_string_pretty(&cw_request) { + let uuid_prefix = uuid::Uuid::new_v4() + .to_string() + .split('-') + .next() + .unwrap_or("unknown") + .to_string(); + let debug_path = dirs::home_dir() + .unwrap_or_default() + .join(".proxycast") + .join("logs") + .join(format!("cw_request_{uuid_prefix}.json")); + let _ = tokio::fs::write(&debug_path, &json_str).await; + tracing::debug!("[CW_REQ] Request saved to {:?}", debug_path); + } } + // 记录历史消息数量和 tool_results 情况(不落盘) + let history_len = cw_request + .conversation_state + .history + .as_ref() + .map(|h| h.len()) + .unwrap_or(0); + let current_has_tools = cw_request + .conversation_state + .current_message + .user_input_message + .user_input_message_context + .as_ref() + .map(|ctx| ctx.tool_results.as_ref().map(|tr| tr.len()).unwrap_or(0)) + .unwrap_or(0); + tracing::info!( + "[CW_REQ] history={} current_tool_results={}", + history_len, + current_has_tools + ); + // 生成设备指纹用于伪装 Kiro IDE let device_fp = get_device_fingerprint(); let kiro_version = get_kiro_version(); diff --git a/src-tauri/src/providers/qwen.rs b/src-tauri/src/providers/qwen.rs index 403c4161e..e1ca9779a 100644 --- a/src-tauri/src/providers/qwen.rs +++ b/src-tauri/src/providers/qwen.rs @@ -136,8 +136,10 @@ impl QwenProvider { if let Some(expire_str) = &self.credentials.expire { if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(expire_str) { let now = chrono::Utc::now(); + // 安全修复:显式转换为 Utc 时区再比较 + let expires_utc = expires.with_timezone(&chrono::Utc); // Token 有效期需要超过 30 秒 - return expires > now + chrono::Duration::seconds(30); + return expires_utc > now + chrono::Duration::seconds(30); } } @@ -147,7 +149,8 @@ impl QwenProvider { return expiry > now + 30_000; } - true + // 安全修复:没有过期时间时采用保守策略,认为 token 无效 + false } pub fn get_base_url(&self) -> String { diff --git a/src-tauri/src/router/provider_router.rs b/src-tauri/src/router/provider_router.rs index c302eb951..972ba1342 100644 --- a/src-tauri/src/router/provider_router.rs +++ b/src-tauri/src/router/provider_router.rs @@ -101,22 +101,16 @@ impl ProviderRouter { // /{selector}/v1/messages [selector, "v1", "messages"] => { let registry = self.registry.read().await; - let route = registry - .find_by_selector(selector) - .cloned() - .unwrap_or_else(|| { - // 创建一个临时的选择器路由 - RegisteredRoute { - path_pattern: format!("/{}/v1/messages", selector), - route_type: RouteType::CredentialSelector, - provider_type: None, - credential_uuid: None, - credential_name: Some(selector.to_string()), - protocols: vec!["claude".to_string()], - enabled: true, - priority: 50, - } - }); + let route = registry.find_by_selector(selector).cloned(); + + // 安全修复:未注册的 selector 不创建临时路由,直接返回 None + let route = match route { + Some(r) => r, + None => { + tracing::warn!("[ROUTER] 未注册的 selector: {},拒绝请求", selector); + return None; + } + }; Some(RouteMatch { route, @@ -128,19 +122,16 @@ impl ProviderRouter { // /{selector}/v1/chat/completions [selector, "v1", "chat", "completions"] => { let registry = self.registry.read().await; - let route = registry - .find_by_selector(selector) - .cloned() - .unwrap_or_else(|| RegisteredRoute { - path_pattern: format!("/{}/v1/chat/completions", selector), - route_type: RouteType::CredentialSelector, - provider_type: None, - credential_uuid: None, - credential_name: Some(selector.to_string()), - protocols: vec!["openai".to_string()], - enabled: true, - priority: 50, - }); + let route = registry.find_by_selector(selector).cloned(); + + // 安全修复:未注册的 selector 不创建临时路由,直接返回 None + let route = match route { + Some(r) => r, + None => { + tracing::warn!("[ROUTER] 未注册的 selector: {},拒绝请求", selector); + return None; + } + }; Some(RouteMatch { route, diff --git a/src/components/settings/RemoteManagementSettings.tsx b/src/components/settings/RemoteManagementSettings.tsx index ce8f347a7..866f6a33a 100644 --- a/src/components/settings/RemoteManagementSettings.tsx +++ b/src/components/settings/RemoteManagementSettings.tsx @@ -70,12 +70,14 @@ export function RemoteManagementSettings() { }; const generateSecretKey = () => { - const chars = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - let key = ""; - for (let i = 0; i < 32; i++) { - key += chars.charAt(Math.floor(Math.random() * chars.length)); - } + // 安全修复:使用 WebCrypto API 生成安全随机密钥 + const array = new Uint8Array(32); + crypto.getRandomValues(array); + // 转换为 base64url 格式(URL 安全的 base64) + const key = btoa(String.fromCharCode(...array)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); updateRemoteManagement({ secret_key: key }); };