From cf79b09597e31ae82da890ea170c74b393cc0145 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Wed, 31 Dec 2025 17:15:00 +0800 Subject: [PATCH] feat(proxy): add body and header filtering for upstream requests --- src-tauri/src/proxy/body_filter.rs | 206 +++++++++++++++++++++++++++++ src-tauri/src/proxy/forwarder.rs | 174 +++++++++++++++++++++++- src-tauri/src/proxy/mod.rs | 1 + 3 files changed, 377 insertions(+), 4 deletions(-) create mode 100644 src-tauri/src/proxy/body_filter.rs diff --git a/src-tauri/src/proxy/body_filter.rs b/src-tauri/src/proxy/body_filter.rs new file mode 100644 index 000000000..49276977a --- /dev/null +++ b/src-tauri/src/proxy/body_filter.rs @@ -0,0 +1,206 @@ +//! 请求体过滤模块 +//! +//! 过滤不应透传到上游的私有参数,防止内部信息泄露。 +//! +//! ## 过滤规则 +//! - 以 `_` 开头的字段被视为私有参数,会被递归过滤 +//! - 支持嵌套对象和数组的深度过滤 +//! +//! ## 使用场景 +//! - `_internal_id`: 内部追踪 ID +//! - `_debug_mode`: 调试标记 +//! - `_session_token`: 会话令牌 +//! - `_client_version`: 客户端版本 + +use serde_json::Value; + +/// 过滤私有参数(以 `_` 开头的字段) +/// +/// 递归遍历 JSON 结构,移除所有以下划线开头的字段。 +/// +/// # Arguments +/// * `body` - 原始请求体 +/// +/// # Returns +/// 过滤后的请求体 +/// +/// # Example +/// ```ignore +/// let input = json!({ +/// "model": "claude-3", +/// "_internal_id": "abc123", +/// "messages": [{"role": "user", "content": "hello", "_token": "secret"}] +/// }); +/// let output = filter_private_params(input); +/// // output 中不包含 _internal_id 和 _token +/// ``` +pub fn filter_private_params(body: Value) -> Value { + filter_recursive(body, &mut Vec::new()) +} + +/// 递归过滤实现 +fn filter_recursive(value: Value, removed_keys: &mut Vec) -> Value { + match value { + Value::Object(map) => { + let filtered: serde_json::Map = map + .into_iter() + .filter_map(|(key, val)| { + if key.starts_with('_') { + removed_keys.push(key); + None + } else { + Some((key, filter_recursive(val, removed_keys))) + } + }) + .collect(); + + // 仅在有过滤时记录日志(避免每次请求都打印) + if !removed_keys.is_empty() { + log::debug!("[BodyFilter] 过滤私有参数: {removed_keys:?}"); + removed_keys.clear(); + } + + Value::Object(filtered) + } + Value::Array(arr) => Value::Array( + arr.into_iter() + .map(|v| filter_recursive(v, removed_keys)) + .collect(), + ), + other => other, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_filter_top_level_private_params() { + let input = json!({ + "model": "claude-3", + "_internal_id": "abc123", + "_debug": true, + "max_tokens": 1024 + }); + + let output = filter_private_params(input); + + assert!(output.get("model").is_some()); + assert!(output.get("max_tokens").is_some()); + assert!(output.get("_internal_id").is_none()); + assert!(output.get("_debug").is_none()); + } + + #[test] + fn test_filter_nested_private_params() { + let input = json!({ + "model": "claude-3", + "messages": [ + { + "role": "user", + "content": "hello", + "_session_token": "secret" + } + ], + "metadata": { + "user_id": "user-1", + "_tracking_id": "track-1" + } + }); + + let output = filter_private_params(input); + + // 顶级字段保留 + assert!(output.get("model").is_some()); + assert!(output.get("messages").is_some()); + assert!(output.get("metadata").is_some()); + + // messages 数组中的私有参数被过滤 + let messages = output.get("messages").unwrap().as_array().unwrap(); + assert!(messages[0].get("role").is_some()); + assert!(messages[0].get("content").is_some()); + assert!(messages[0].get("_session_token").is_none()); + + // metadata 对象中的私有参数被过滤 + let metadata = output.get("metadata").unwrap(); + assert!(metadata.get("user_id").is_some()); + assert!(metadata.get("_tracking_id").is_none()); + } + + #[test] + fn test_filter_deeply_nested() { + let input = json!({ + "level1": { + "level2": { + "level3": { + "keep": "value", + "_remove": "secret" + } + } + } + }); + + let output = filter_private_params(input); + + let level3 = output + .get("level1") + .unwrap() + .get("level2") + .unwrap() + .get("level3") + .unwrap(); + + assert!(level3.get("keep").is_some()); + assert!(level3.get("_remove").is_none()); + } + + #[test] + fn test_filter_array_of_objects() { + let input = json!({ + "items": [ + {"id": 1, "_secret": "a"}, + {"id": 2, "_secret": "b"}, + {"id": 3, "_secret": "c"} + ] + }); + + let output = filter_private_params(input); + let items = output.get("items").unwrap().as_array().unwrap(); + + for item in items { + assert!(item.get("id").is_some()); + assert!(item.get("_secret").is_none()); + } + } + + #[test] + fn test_no_private_params() { + let input = json!({ + "model": "claude-3", + "messages": [{"role": "user", "content": "hello"}] + }); + + let output = filter_private_params(input.clone()); + + // 无私有参数时,输出应与输入相同 + assert_eq!(input, output); + } + + #[test] + fn test_empty_object() { + let input = json!({}); + let output = filter_private_params(input); + assert_eq!(output, json!({})); + } + + #[test] + fn test_primitive_values() { + // 原始值不应被修改 + assert_eq!(filter_private_params(json!(42)), json!(42)); + assert_eq!(filter_private_params(json!("string")), json!("string")); + assert_eq!(filter_private_params(json!(true)), json!(true)); + assert_eq!(filter_private_params(json!(null)), json!(null)); + } +} diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 88b8d3a6d..9d2741644 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -3,6 +3,7 @@ //! 负责将请求转发到上游Provider,支持故障转移 use super::{ + body_filter::filter_private_params, error::*, failover_switch::FailoverSwitchManager, provider_router::ProviderRouter, @@ -17,6 +18,74 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::RwLock; +/// Headers 黑名单 - 不透传到上游的 Headers +/// +/// 参考 Claude Code Hub 设计,过滤以下类别: +/// 1. 认证类(会被覆盖) +/// 2. 连接类(由 HTTP 客户端管理) +/// 3. 客户端 IP 类(隐私保护) +/// 4. 代理转发类 +/// 5. CDN/云服务商特定头 +/// 6. 请求追踪类 +/// 7. 浏览器特定头(可能被上游检测) +const HEADER_BLACKLIST: &[&str] = &[ + // 认证类(会被覆盖) + "authorization", + "x-api-key", + // 连接类 + "host", + "content-length", + "connection", + "transfer-encoding", + // 编码类(会被覆盖为 identity) + "accept-encoding", + // 客户端 IP 类(隐私保护) + "x-forwarded-for", + "x-real-ip", + "x-client-ip", + "x-originating-ip", + "x-remote-ip", + "x-remote-addr", + // 代理转发类 + "x-forwarded-host", + "x-forwarded-port", + "x-forwarded-proto", + "forwarded", + // CDN/云服务商特定头 + "cf-connecting-ip", + "cf-ipcountry", + "cf-ray", + "cf-visitor", + "true-client-ip", + "fastly-client-ip", + "x-azure-clientip", + "x-azure-fdid", + "x-azure-ref", + "akamai-origin-hop", + "x-akamai-config-log-detail", + // 请求追踪类 + "x-request-id", + "x-correlation-id", + "x-trace-id", + "x-amzn-trace-id", + "x-b3-traceid", + "x-b3-spanid", + "x-b3-parentspanid", + "x-b3-sampled", + "traceparent", + "tracestate", + // 浏览器特定头(可能被上游检测为非 CLI 请求) + "sec-fetch-mode", + "sec-fetch-site", + "sec-fetch-dest", + "sec-ch-ua", + "sec-ch-ua-mobile", + "sec-ch-ua-platform", + "accept-language", + // anthropic-beta 单独处理,避免重复 + "anthropic-beta", +]; + pub struct ForwardResult { pub response: Response, pub provider: Provider, @@ -420,6 +489,27 @@ impl RequestForwarder { mapped_body }; + // 过滤私有参数(以 `_` 开头的字段),防止内部信息泄露到上游 + let filtered_body = filter_private_params(request_body); + + // ========== 请求体日志(截断显示) ========== + let body_str = serde_json::to_string_pretty(&filtered_body) + .unwrap_or_else(|_| filtered_body.to_string()); + let body_preview = if body_str.len() > 2000 { + format!( + "{}...\n[截断,总长度: {} 字符]", + &body_str[..2000], + body_str.len() + ) + } else { + body_str + }; + log::info!( + "[{}] ====== 最终请求体 ======\n{}", + adapter.name(), + body_preview + ); + log::info!( "[{}] 转发请求: {} -> {}", adapter.name(), @@ -430,17 +520,74 @@ impl RequestForwarder { // 构建请求 let mut request = self.client.post(&url); - // 请求头黑名单:仅跳过会被覆盖或可能失效的字段(认证、Host、长度) - let skip_headers = ["authorization", "x-api-key", "host", "content-length"]; + // ========== 详细 Headers 日志 ========== + log::info!("[{}] ====== 客户端原始 Headers ======", adapter.name()); + for (key, value) in headers { + log::info!( + "[{}] {}: {:?}", + adapter.name(), + key.as_str(), + value.to_str().unwrap_or("") + ); + } + + // 过滤黑名单 Headers,保护隐私并避免冲突 + let mut filtered_headers: Vec = Vec::new(); + let mut passed_headers: Vec<(String, String)> = Vec::new(); for (key, value) in headers { let key_str = key.as_str().to_lowercase(); - if skip_headers.contains(&key_str.as_str()) { + if HEADER_BLACKLIST.contains(&key_str.as_str()) { + filtered_headers.push(key_str); continue; } + let value_str = value.to_str().unwrap_or("").to_string(); + passed_headers.push((key.as_str().to_string(), value_str.clone())); request = request.header(key, value); } + if !filtered_headers.is_empty() { + log::info!( + "[{}] ====== 被过滤的 Headers ({}) ======", + adapter.name(), + filtered_headers.len() + ); + for h in &filtered_headers { + log::info!("[{}] - {}", adapter.name(), h); + } + } + + // 处理 anthropic-beta Header + // 必须移除 claude-code-xxxx 标记,上游服务 (free.duckcoding.com) 似乎会拒绝包含此 tag 的请求 + // 报错信息: "请勿在 Claude Code CLI 之外使用接口" (paradoxically triggered when this tag is present) + if let Some(beta) = headers.get("anthropic-beta") { + let beta_str = beta.to_str().unwrap_or(""); + let filtered_beta: Vec<&str> = beta_str + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.contains("claude-code")) + .collect(); + + if !filtered_beta.is_empty() { + let new_beta_value = filtered_beta.join(","); + request = request.header("anthropic-beta", &new_beta_value); + passed_headers.push(("anthropic-beta".to_string(), new_beta_value.clone())); + log::info!( + "[{}] 处理 anthropic-beta: {} -> {}", + adapter.name(), + beta_str, + new_beta_value + ); + } else { + log::info!("[{}] 过滤后 anthropic-beta 为空,跳过发送", adapter.name()); + } + } + + // 禁用压缩,避免 gzip 流式响应解析错误 + // 参考 CCH: undici 在连接提前关闭时会对不完整的 gzip 流抛出错误 + request = request.header("accept-encoding", "identity"); + passed_headers.push(("accept-encoding".to_string(), "identity".to_string())); + // 使用适配器添加认证头 if let Some(auth) = adapter.extract_auth(provider) { log::debug!( @@ -450,6 +597,15 @@ impl RequestForwarder { auth.masked_key() ); request = adapter.add_auth_headers(request, &auth); + // 记录认证头(脱敏) + passed_headers.push(( + "authorization".to_string(), + format!("Bearer {}...", &auth.api_key[..8.min(auth.api_key.len())]), + )); + passed_headers.push(( + "x-api-key".to_string(), + format!("{}...", &auth.api_key[..8.min(auth.api_key.len())]), + )); } else { log::error!( "[{}] 未找到 API Key!Provider: {}", @@ -458,9 +614,19 @@ impl RequestForwarder { ); } + // ========== 最终发送的 Headers 日志 ========== + log::info!( + "[{}] ====== 最终发送的 Headers ({}) ======", + adapter.name(), + passed_headers.len() + ); + for (k, v) in &passed_headers { + log::info!("[{}] {}: {}", adapter.name(), k, v); + } + // 发送请求 log::info!("[{}] 发送请求到: {}", adapter.name(), url); - let response = request.json(&request_body).send().await.map_err(|e| { + let response = request.json(&filtered_body).send().await.map_err(|e| { log::error!("[{}] 请求失败: {}", adapter.name(), e); if e.is_timeout() { ProxyError::Timeout(format!("请求超时: {e}")) diff --git a/src-tauri/src/proxy/mod.rs b/src-tauri/src/proxy/mod.rs index 69063dc08..d8e0e6deb 100644 --- a/src-tauri/src/proxy/mod.rs +++ b/src-tauri/src/proxy/mod.rs @@ -2,6 +2,7 @@ //! //! 提供本地HTTP代理服务,支持多Provider故障转移和请求透传 +pub mod body_filter; pub mod circuit_breaker; pub mod error; pub mod error_mapper;