fix(llm): 自定义头注入安全性修复 + 测试覆盖补全
三 Provider 自定义 HTTP 头机制的审查后修复: - 安全性:build_request_builder 改用 HeaderName::from_bytes / HeaderValue::from_str 安全转换;非法 header 名/值(如控制字符)静默跳过 + warn,避免 reqwest panic。 - doc comment:with_extra_headers() 的"注入"措辞与"替换"语义不符 (self.extra_headers = headers),改为"设置 Provider 级别固定头,替换已有的"。 - 测试覆盖:补全方案验证标准缺失的 *_extra_headers_from_constructor 单元测试 (三 provider 各 2 个,共 6 新增),用 RequestBuilder::build() 直检 headers。
This commit is contained in:
@@ -14,7 +14,7 @@ use bytes::Bytes;
|
||||
use futures_core::Stream;
|
||||
use futures_util::StreamExt;
|
||||
use reqwest::Client;
|
||||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use tracing::{debug, error, info, warn};
|
||||
@@ -151,7 +151,8 @@ impl AnthropicProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// 注入 Provider 级别固定头。返回 self 以支持链式调用。
|
||||
/// 设置 Provider 级别固定头,替换已有的 extra_headers(如有)。
|
||||
/// 返回 self 以支持链式调用。如需追加语义,在外部自行 `extend`。
|
||||
pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
|
||||
self.extra_headers = headers;
|
||||
self
|
||||
@@ -276,6 +277,11 @@ impl AnthropicProvider {
|
||||
///
|
||||
/// 头融合顺序:认证头(default_headers)→ Provider 级 extra_headers → 请求级 custom_headers
|
||||
/// 后者覆盖前者。
|
||||
/// 构造 HTTP POST 请求 builder(含认证头 + 自定义头)。
|
||||
/// 认证头(x-api-key / anthropic-version)已由 Client 的 default_headers 提供。
|
||||
///
|
||||
/// 头融合顺序:认证头(default_headers)→ Provider 级 extra_headers → 请求级 custom_headers
|
||||
/// 后者覆盖前者。非法 header 名/值(如控制字符)静默跳过 + warn,避免 reqwest panic。
|
||||
fn build_request_builder(
|
||||
&self,
|
||||
body: &AnthropicRequestBody,
|
||||
@@ -284,11 +290,25 @@ impl AnthropicProvider {
|
||||
let mut builder = self.http_client.post(&url).json(body);
|
||||
|
||||
for (k, v) in &self.extra_headers {
|
||||
builder = builder.header(k.as_str(), v.as_str());
|
||||
if let (Ok(name), Ok(value)) = (
|
||||
HeaderName::from_bytes(k.as_bytes()),
|
||||
HeaderValue::from_str(v),
|
||||
) {
|
||||
builder = builder.header(name, value);
|
||||
} else {
|
||||
warn!(header = %k, "skipping invalid extra_header (key or value contains illegal characters)");
|
||||
}
|
||||
}
|
||||
|
||||
for (key, value) in &body.custom_headers {
|
||||
builder = builder.header(key.as_str(), value.as_str());
|
||||
if let (Ok(name), Ok(value)) = (
|
||||
HeaderName::from_bytes(key.as_bytes()),
|
||||
HeaderValue::from_str(value),
|
||||
) {
|
||||
builder = builder.header(name, value);
|
||||
} else {
|
||||
warn!(header = %key, "skipping invalid custom_header (key or value contains illegal characters)");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(builder)
|
||||
@@ -1436,4 +1456,78 @@ event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n";
|
||||
"custom_headers 应能覆盖 x-api-key 头,实际收到: {keys:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_extra_headers_from_constructor_unit() {
|
||||
// ponytail: 用 RequestBuilder::build() 直检 headers,无需 wiremock。
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("create http client");
|
||||
let provider = AnthropicProvider::from_parts(
|
||||
"http://x".into(),
|
||||
"sk-ant-test".into(),
|
||||
"claude-sonnet-4-20250514".into(),
|
||||
client,
|
||||
30,
|
||||
vec![("X-Platform".into(), "anthropic-test".into())],
|
||||
);
|
||||
let body = AnthropicRequestBody {
|
||||
model: "claude-sonnet-4-20250514".into(),
|
||||
max_tokens: 4096,
|
||||
system: None,
|
||||
messages: Vec::new(),
|
||||
tools: None,
|
||||
thinking: None,
|
||||
stream: None,
|
||||
custom_headers: HashMap::new(),
|
||||
};
|
||||
let req = provider
|
||||
.build_request_builder(&body)
|
||||
.unwrap()
|
||||
.build()
|
||||
.unwrap();
|
||||
assert_eq!(req.headers().get("X-Platform").unwrap(), "anthropic-test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_invalid_header_name_is_skipped() {
|
||||
// ponytail: extra_headers 含非法 key 应被跳过,不应让 reqwest panic。
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("create http client");
|
||||
let provider = AnthropicProvider::from_parts(
|
||||
"http://x".into(),
|
||||
"sk-ant-test".into(),
|
||||
"claude-sonnet-4-20250514".into(),
|
||||
client,
|
||||
30,
|
||||
Vec::new(),
|
||||
)
|
||||
.with_extra_headers(vec![
|
||||
("X-Valid".into(), "v1".into()),
|
||||
("bad\nname".into(), "v2".into()),
|
||||
]);
|
||||
let body = AnthropicRequestBody {
|
||||
model: "claude-sonnet-4-20250514".into(),
|
||||
max_tokens: 4096,
|
||||
system: None,
|
||||
messages: Vec::new(),
|
||||
tools: None,
|
||||
thinking: None,
|
||||
stream: None,
|
||||
custom_headers: HashMap::new(),
|
||||
};
|
||||
let req = provider
|
||||
.build_request_builder(&body)
|
||||
.unwrap()
|
||||
.build()
|
||||
.unwrap();
|
||||
assert_eq!(req.headers().get("X-Valid").unwrap(), "v1");
|
||||
assert!(
|
||||
req.headers().get("bad\nname").is_none(),
|
||||
"非法 header 名应被静默跳过"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,10 @@ use bytes::Bytes;
|
||||
use futures_core::Stream;
|
||||
use futures_util::StreamExt;
|
||||
use reqwest::Client;
|
||||
use reqwest::header::{HeaderName, HeaderValue};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use tracing::{debug, error, info};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::llm::convert::{from_openai, to_openai};
|
||||
use crate::llm::error::LlmError;
|
||||
@@ -443,7 +444,8 @@ impl GenericOpenaiProvider {
|
||||
)
|
||||
}
|
||||
|
||||
/// 注入 Provider 级别固定头。返回 self 以支持链式调用。
|
||||
/// 设置 Provider 级别固定头,替换已有的 extra_headers(如有)。
|
||||
/// 返回 self 以支持链式调用。如需追加语义,在外部自行 `extend`。
|
||||
pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
|
||||
self.extra_headers = headers;
|
||||
self
|
||||
@@ -465,7 +467,7 @@ impl GenericOpenaiProvider {
|
||||
/// 构造 HTTP POST 请求 builder(含认证头与额外请求头)。
|
||||
///
|
||||
/// 头融合顺序:Authorization → Provider 级 extra_headers → 请求级 custom_headers
|
||||
/// 后者覆盖前者。
|
||||
/// 后者覆盖前者。非法 header 名/值(如控制字符)静默跳过 + warn,避免 reqwest panic。
|
||||
fn build_request_builder(
|
||||
&self,
|
||||
url: &str,
|
||||
@@ -476,10 +478,24 @@ impl GenericOpenaiProvider {
|
||||
.post(url)
|
||||
.header("Authorization", format!("Bearer {}", self.api_key));
|
||||
for (k, v) in &self.extra_headers {
|
||||
builder = builder.header(k.as_str(), v.as_str());
|
||||
if let (Ok(name), Ok(value)) = (
|
||||
HeaderName::from_bytes(k.as_bytes()),
|
||||
HeaderValue::from_str(v),
|
||||
) {
|
||||
builder = builder.header(name, value);
|
||||
} else {
|
||||
warn!(header = %k, "skipping invalid extra_header (key or value contains illegal characters)");
|
||||
}
|
||||
}
|
||||
for (key, value) in &body.custom_headers {
|
||||
builder = builder.header(key.as_str(), value.as_str());
|
||||
if let (Ok(name), Ok(value)) = (
|
||||
HeaderName::from_bytes(key.as_bytes()),
|
||||
HeaderValue::from_str(value),
|
||||
) {
|
||||
builder = builder.header(name, value);
|
||||
} else {
|
||||
warn!(header = %key, "skipping invalid custom_header (key or value contains illegal characters)");
|
||||
}
|
||||
}
|
||||
Ok(builder.json(body))
|
||||
}
|
||||
@@ -1901,4 +1917,55 @@ data: [DONE]\n\n";
|
||||
"custom_headers 应能覆盖 Authorization 头,实际收到: {auth_values:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_chat_extra_headers_from_constructor_unit() {
|
||||
// 验证 `from_parts` 传入的 extra_headers 在 build_request_builder 中被注入。
|
||||
// ponytail: 用 RequestBuilder::build() 直检 headers,无需 wiremock。
|
||||
let provider = GenericOpenaiProvider::from_parts(
|
||||
"http://x".into(),
|
||||
"sk-test".into(),
|
||||
"gpt-4o".into(),
|
||||
"openai",
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("create http client"),
|
||||
vec![("X-Platform".into(), "doubao".into())],
|
||||
30,
|
||||
);
|
||||
let body = OpenaiChatRequest {
|
||||
model: "gpt-4o".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let req = provider
|
||||
.build_request_builder("http://x/chat/completions", &body)
|
||||
.unwrap()
|
||||
.build()
|
||||
.unwrap();
|
||||
assert_eq!(req.headers().get("X-Platform").unwrap(), "doubao");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_chat_invalid_header_name_is_skipped() {
|
||||
// ponytail: extra_headers 含非法 key(如换行符)应被跳过,不应让 reqwest panic。
|
||||
let provider = make_provider_for_header_tests("http://x".into()).with_extra_headers(vec![
|
||||
("X-Valid".into(), "v1".into()),
|
||||
("bad\nname".into(), "v2".into()),
|
||||
]);
|
||||
let body = OpenaiChatRequest {
|
||||
model: "gpt-4o".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let req = provider
|
||||
.build_request_builder("http://x/chat/completions", &body)
|
||||
.unwrap()
|
||||
.build()
|
||||
.unwrap();
|
||||
assert_eq!(req.headers().get("X-Valid").unwrap(), "v1");
|
||||
assert!(
|
||||
req.headers().get("bad\nname").is_none(),
|
||||
"非法 header 名应被静默跳过"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ use bytes::Bytes;
|
||||
use futures_core::Stream;
|
||||
use futures_util::StreamExt;
|
||||
use reqwest::Client;
|
||||
use reqwest::header::{HeaderName, HeaderValue};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use tracing::{debug, error, info, warn};
|
||||
@@ -321,7 +322,8 @@ impl OpenaiResponseProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// 注入 Provider 级别固定头。返回 self 以支持链式调用。
|
||||
/// 设置 Provider 级别固定头,替换已有的 extra_headers(如有)。
|
||||
/// 返回 self 以支持链式调用。如需追加语义,在外部自行 `extend`。
|
||||
pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
|
||||
self.extra_headers = headers;
|
||||
self
|
||||
@@ -331,6 +333,8 @@ impl OpenaiResponseProvider {
|
||||
format!("{}/responses", self.base_url.trim_end_matches('/'))
|
||||
}
|
||||
|
||||
/// 头融合顺序:Authorization → Provider 级 extra_headers → 请求级 custom_headers
|
||||
/// 后者覆盖前者。非法 header 名/值(如控制字符)静默跳过 + warn,避免 reqwest panic。
|
||||
fn build_request_builder(
|
||||
&self,
|
||||
body: &OpenaiResponseRequest,
|
||||
@@ -341,11 +345,25 @@ impl OpenaiResponseProvider {
|
||||
.header("Authorization", format!("Bearer {}", self.api_key));
|
||||
|
||||
for (k, v) in &self.extra_headers {
|
||||
builder = builder.header(k.as_str(), v.as_str());
|
||||
if let (Ok(name), Ok(value)) = (
|
||||
HeaderName::from_bytes(k.as_bytes()),
|
||||
HeaderValue::from_str(v),
|
||||
) {
|
||||
builder = builder.header(name, value);
|
||||
} else {
|
||||
warn!(header = %k, "skipping invalid extra_header (key or value contains illegal characters)");
|
||||
}
|
||||
}
|
||||
|
||||
for (key, value) in &body.custom_headers {
|
||||
builder = builder.header(key.as_str(), value.as_str());
|
||||
if let (Ok(name), Ok(value)) = (
|
||||
HeaderName::from_bytes(key.as_bytes()),
|
||||
HeaderValue::from_str(value),
|
||||
) {
|
||||
builder = builder.header(name, value);
|
||||
} else {
|
||||
warn!(header = %key, "skipping invalid custom_header (key or value contains illegal characters)");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(builder.json(body))
|
||||
@@ -2196,4 +2214,96 @@ data: {\"type\":\"response.failed\",\"error\":{\"message\":\"server failed mid-s
|
||||
"custom_headers 应能覆盖 Authorization 头,实际收到: {auth_values:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_response_extra_headers_from_constructor_unit() {
|
||||
// ponytail: 用 RequestBuilder::build() 直检 headers,无需 wiremock。
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("create http client");
|
||||
let provider = OpenaiResponseProvider::from_parts(
|
||||
"http://x".into(),
|
||||
"sk-test".into(),
|
||||
"gpt-4o".into(),
|
||||
client,
|
||||
30,
|
||||
vec![("X-Platform".into(), "doubao".into())],
|
||||
);
|
||||
let body = OpenaiResponseRequest {
|
||||
model: "gpt-4o".into(),
|
||||
instructions: None,
|
||||
input: Vec::new(),
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
text: None,
|
||||
max_output_tokens: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
stop: None,
|
||||
stream: None,
|
||||
previous_response_id: None,
|
||||
store: None,
|
||||
truncation: None,
|
||||
metadata: None,
|
||||
reasoning: None,
|
||||
custom_headers: HashMap::new(),
|
||||
};
|
||||
let req = provider
|
||||
.build_request_builder(&body)
|
||||
.unwrap()
|
||||
.build()
|
||||
.unwrap();
|
||||
assert_eq!(req.headers().get("X-Platform").unwrap(), "doubao");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_response_invalid_header_name_is_skipped() {
|
||||
// ponytail: extra_headers 含非法 key 应被跳过,不应让 reqwest panic。
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("create http client");
|
||||
let provider = OpenaiResponseProvider::from_parts(
|
||||
"http://x".into(),
|
||||
"sk-test".into(),
|
||||
"gpt-4o".into(),
|
||||
client,
|
||||
30,
|
||||
Vec::new(),
|
||||
)
|
||||
.with_extra_headers(vec![
|
||||
("X-Valid".into(), "v1".into()),
|
||||
("bad\nname".into(), "v2".into()),
|
||||
]);
|
||||
let body = OpenaiResponseRequest {
|
||||
model: "gpt-4o".into(),
|
||||
instructions: None,
|
||||
input: Vec::new(),
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
text: None,
|
||||
max_output_tokens: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
stop: None,
|
||||
stream: None,
|
||||
previous_response_id: None,
|
||||
store: None,
|
||||
truncation: None,
|
||||
metadata: None,
|
||||
reasoning: None,
|
||||
custom_headers: HashMap::new(),
|
||||
};
|
||||
let req = provider
|
||||
.build_request_builder(&body)
|
||||
.unwrap()
|
||||
.build()
|
||||
.unwrap();
|
||||
assert_eq!(req.headers().get("X-Valid").unwrap(), "v1");
|
||||
assert!(
|
||||
req.headers().get("bad\nname").is_none(),
|
||||
"非法 header 名应被静默跳过"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user