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:
徐涛
2026-07-20 14:58:32 +08:00
parent 77321db8f6
commit 76bbeed596
3 changed files with 283 additions and 12 deletions
+98 -4
View File
@@ -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 名应被静默跳过"
);
}
}
+72 -5
View File
@@ -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 名应被静默跳过"
);
}
}
+113 -3
View File
@@ -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 名应被静默跳过"
);
}
}