mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 19:45:34 +08:00
fix(pi): validate deferred headers after resolution
This commit is contained in:
@@ -164,7 +164,7 @@ impl DeferredHeaderValue {
|
||||
} else {
|
||||
self.raw.clone()
|
||||
};
|
||||
HeaderValue::from_str(&materialized).map_err(|_| PiGatewayReason {
|
||||
parse_transport_header_value(&materialized).ok_or_else(|| PiGatewayReason {
|
||||
code: PiGatewayReasonCode::InvalidHeaderValue,
|
||||
json_pointer: pointer.to_string(),
|
||||
})
|
||||
@@ -262,7 +262,7 @@ impl CandidateHeaderPlan {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if HeaderValue::from_str(value).is_err() {
|
||||
if !is_deferred(value) && parse_transport_header_value(value).is_none() {
|
||||
reasons.push(PiGatewayReason {
|
||||
code: PiGatewayReasonCode::InvalidHeaderValue,
|
||||
json_pointer: pointer,
|
||||
@@ -431,6 +431,13 @@ fn is_deferred(value: &str) -> bool {
|
||||
value.starts_with('!') || value.contains('$')
|
||||
}
|
||||
|
||||
fn parse_transport_header_value(value: &str) -> Option<HeaderValue> {
|
||||
if !value.bytes().all(|byte| matches!(byte, 0x20..=0x7e)) {
|
||||
return None;
|
||||
}
|
||||
HeaderValue::from_str(value).ok()
|
||||
}
|
||||
|
||||
fn escape_json_pointer(value: &str) -> String {
|
||||
value.replace('~', "~0").replace('/', "~1")
|
||||
}
|
||||
@@ -693,6 +700,46 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_header_values_use_one_post_resolution_validator() {
|
||||
let expression = "!echo café";
|
||||
let composition = composed(json!({
|
||||
"api": "openai-responses",
|
||||
"baseUrl": "https://candidate.example/v1",
|
||||
"apiKey": "literal",
|
||||
"headers": {"x-tenant": expression},
|
||||
"models": [{"id": "m"}]
|
||||
}));
|
||||
let plan = assess_composition(&composition).plans.remove(0);
|
||||
let resolved = plan
|
||||
.materialize(&|value: &str| {
|
||||
(value == expression).then(|| "resolved-secret".to_string())
|
||||
})
|
||||
.expect("the resolved visible-ASCII value is valid");
|
||||
assert_eq!(
|
||||
resolved.headers[&HeaderName::from_static("x-tenant")],
|
||||
"resolved-secret"
|
||||
);
|
||||
assert_eq!(
|
||||
plan.materialize(&|value: &str| (value == expression).then(|| "café".to_string()))
|
||||
.expect_err("the resolved value still passes through transport validation")
|
||||
.code,
|
||||
PiGatewayReasonCode::InvalidHeaderValue
|
||||
);
|
||||
|
||||
let literal = composed(json!({
|
||||
"api": "openai-responses",
|
||||
"baseUrl": "https://candidate.example/v1",
|
||||
"apiKey": "literal",
|
||||
"headers": {"x-tenant": "café"},
|
||||
"models": [{"id": "m"}]
|
||||
}));
|
||||
assert_eq!(
|
||||
assess_composition(&literal).reasons[0].code,
|
||||
PiGatewayReasonCode::InvalidHeaderValue
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_header_adds_candidate_local_bearer_without_reusing_another_candidate() {
|
||||
let composition = composed(json!({
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
//! 盲审前置条件而非充分条件。既有测试不得弱化——以测试总数只增不减 + 盲审
|
||||
//! 核对为准,不设扫描器。
|
||||
//!
|
||||
//! ## 三条裁决及其上游证据
|
||||
//! ## 四条裁决及其上游证据
|
||||
//! C1【无损性】pinned schema 对 `thinkingLevelMap` 只约束 7 个标准键
|
||||
//! (string|null;oracle 实证 `low: 2` 非法),额外键无约束(oracle 实证
|
||||
//! `future: {nested:true}` 合法);`cost`/tier 同样接受未来键。managed 与
|
||||
@@ -27,26 +27,36 @@
|
||||
//! 设时显式头优先于 apiKey 合成值(Anthropic/OpenAI SDK 按"合成 auth →
|
||||
//! 显式 headers"合并,后项覆盖);authHeader:true 时合成 Bearer 反过来
|
||||
//! 优先(pinned provider-composer 在自定义头之后写入,且只写
|
||||
//! Authorization、不动 x-api-key)。Google 的 header-only 凭证不是 Pi 原生
|
||||
//! 可请求形态(adapter 无条件要 apiKey),维持 MissingCredential 降级。
|
||||
//! Authorization、不动 x-api-key)。**header-only 凭证对四族都不是 Pi 原生
|
||||
//! 可请求形态**:pinned `ModelRuntime.prepareRequest()` 先解析 auth,得不到
|
||||
//! AuthResult 即抛 "Provider is not configured",在合并 headers 之前返回,
|
||||
//! 而 headers 本身永不产生 AuthResult(Google adapter 更是无条件要 apiKey)。
|
||||
//! 故无 apiKey 时维持 MissingCredential 降级,但认证头本身仍不得被报为
|
||||
//! ProtectedHeader。
|
||||
//! C3【传输层】放宽认证头不得连带放宽传输层:逐跳头完整覆盖并以 `proxy-`
|
||||
//! **前缀**拒绝;契约 header 六分类中的 Gateway/HTTP owned(proxy trace /
|
||||
//! CDN 客户端身份 / 分布式追踪)同样拒绝,清单与生产 forwarder 无条件
|
||||
//! 剥离的集合对齐。
|
||||
//! C4【deferred 值的校验时机】pinned `resolveConfigValueOrThrow()` 先执行
|
||||
//! `!command` / 展开 `${ENV}`,再使用结果;**从不按 HTTP 头规则校验原始
|
||||
//! 表达式**(命令输出 trim,环境模板不 trim,解析结果亦不做头合法性校验)。
|
||||
//! 因此原始表达式含头非法字符、而解析结果合法的配置必须被接受;头合法性
|
||||
//! 校验只能发生在物化之后(这是网关自身的传输约束,保留)。字面量值仍按
|
||||
//! 原样校验。
|
||||
//!
|
||||
//! ## 预期红绿
|
||||
//! 应红 4:`certify_managed_losslessness_through_effective_boundary`、
|
||||
//! `certify_auth_candidate_headers_are_not_protected`、
|
||||
//! `certify_auth_header_bearer_overrides_explicit_authorization`、
|
||||
//! `certify_transport_owned_headers_stay_protected`。
|
||||
//! 应绿 3:夹具冻结、DuplicateModelId 保留、composer 无损。
|
||||
//! 偏离(非清单红、应红变绿、编译失败)即上报。
|
||||
//! ## 预期红绿(2026-08-02 复审修订基线)
|
||||
//! 首轮实现已使 C1/C2/C3 四项转绿。本次修订新增两项:
|
||||
//! `certify_header_only_credentials_stay_direct_only` **应绿**——它把"无
|
||||
//! apiKey 即降级"钉为契约(上游证据见 C2,首轮盲审曾按缺陷提报,现裁定
|
||||
//! 实现正确、契约缺失);`certify_deferred_header_values_are_validated_after_resolution`
|
||||
//! **应红**(C4:现实现对原始表达式做头校验)。
|
||||
//! 即:应红 1、应绿 8。偏离(非清单红、应红变绿、编译失败)即上报。
|
||||
//!
|
||||
//! ## 残余
|
||||
//! 显式优先只对 Anthropic/OpenAI 两族有 SDK 证据(Google 两值并存的优先级、
|
||||
//! OpenAI-Completions、大小写变体未断言);transport oracle 只执行 resolver,
|
||||
//! 不执行 adapter/SDK 头合并,主工程触碰数据面须先补 request-capture oracle;
|
||||
//! 其余按盲审 finding 处理。
|
||||
//! 大小写变体未断言);transport oracle 只执行 resolver,不执行 adapter/SDK
|
||||
//! 头合并,主工程触碰数据面须先补 request-capture oracle;命令输出 trim 与
|
||||
//! 环境模板不 trim 的差异属数据面语义,本只读面不断言;其余按盲审 finding 处理。
|
||||
|
||||
use super::composer::compose_explicit_custom_catalog;
|
||||
use super::gateway::{assess_composition, PiGatewayCapability, PiGatewayReasonCode};
|
||||
@@ -338,7 +348,7 @@ fn certify_auth_candidate_headers_are_not_protected() {
|
||||
// 认证头永远不进 failover 协议身份。
|
||||
if let Some((_, protocol_headers)) = materialized.failover_protocol_identity() {
|
||||
assert!(
|
||||
!protocol_headers.contains_key(&http::HeaderName::from_static("x-api-key")),
|
||||
!protocol_headers.contains_key(http::HeaderName::from_static("x-api-key")),
|
||||
"auth headers must stay out of the failover protocol identity"
|
||||
);
|
||||
}
|
||||
@@ -380,30 +390,6 @@ fn certify_auth_candidate_headers_are_not_protected() {
|
||||
"x-goog-api-key is candidate-auth, not protected"
|
||||
);
|
||||
assert_eq!(gateway.capability, PiGatewayCapability::Proxyable);
|
||||
|
||||
// (d) Google header-only 不是 Pi 原生可请求形态:维持降级,但认证头
|
||||
// 依然不得被报为 ProtectedHeader。
|
||||
let header_only = composed_catalog(json!({
|
||||
"api": "google-generative-ai",
|
||||
"baseUrl": "https://gemini.example",
|
||||
"headers": {"x-goog-api-key": "header-secret"},
|
||||
"models": [{"id": "m"}]
|
||||
}));
|
||||
let gateway = assess_composition(&header_only);
|
||||
assert_eq!(
|
||||
gateway.capability,
|
||||
PiGatewayCapability::DirectOnly,
|
||||
"header-only credentials stay DirectOnly, mirroring pinned Pi"
|
||||
);
|
||||
assert!(has_gateway_reason(
|
||||
&gateway,
|
||||
PiGatewayReasonCode::MissingCredential
|
||||
));
|
||||
assert!(
|
||||
!has_gateway_reason(&gateway, PiGatewayReasonCode::ProtectedHeader),
|
||||
"an auth-candidate header must not be reported as protected even when the \
|
||||
credential is missing"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -526,3 +512,89 @@ fn certify_transport_owned_headers_stay_protected() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 应绿(C2):header-only 凭证四族皆非 Pi 原生可请求形态
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn certify_header_only_credentials_stay_direct_only() {
|
||||
// pinned ModelRuntime.prepareRequest() 先解析 auth,得不到 AuthResult 即抛
|
||||
// "Provider is not configured",在合并 headers 之前返回;headers 永不产生
|
||||
// AuthResult。因此"只有认证头、无 apiKey"必须降级——但认证头本身依然是
|
||||
// candidate-auth,不得被报为 ProtectedHeader。
|
||||
for (api, header) in [
|
||||
("anthropic-messages", "x-api-key"),
|
||||
("openai-completions", "authorization"),
|
||||
("openai-responses", "authorization"),
|
||||
("google-generative-ai", "x-goog-api-key"),
|
||||
] {
|
||||
let composition = composed_catalog(json!({
|
||||
"api": api,
|
||||
"baseUrl": "https://example.test/v1",
|
||||
"headers": {header: "header-secret"},
|
||||
"models": [{"id": "m"}]
|
||||
}));
|
||||
let gateway = assess_composition(&composition);
|
||||
assert_eq!(
|
||||
gateway.capability,
|
||||
PiGatewayCapability::DirectOnly,
|
||||
"{api}: header-only credentials are not a requestable pinned Pi form"
|
||||
);
|
||||
assert!(
|
||||
has_gateway_reason(&gateway, PiGatewayReasonCode::MissingCredential),
|
||||
"{api}: a missing apiKey must be reported as MissingCredential"
|
||||
);
|
||||
assert!(
|
||||
!has_gateway_reason(&gateway, PiGatewayReasonCode::ProtectedHeader),
|
||||
"{api}: the auth header itself must not be reported as protected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 应红(C4):deferred 值只能在物化之后校验
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn certify_deferred_header_values_are_validated_after_resolution() {
|
||||
// 原始表达式含头非法字符(非可见 ASCII),解析结果合法。pinned Pi 先执行
|
||||
// 再用结果,从不校验原始表达式,故这类配置必须被接受。
|
||||
let expression = "!echo café";
|
||||
let deferred = composed_catalog(json!({
|
||||
"api": "openai-responses",
|
||||
"baseUrl": "https://openai.example/v1",
|
||||
"apiKey": "literal",
|
||||
"headers": {"x-tenant": expression},
|
||||
"models": [{"id": "m"}]
|
||||
}));
|
||||
let gateway = assess_composition(&deferred);
|
||||
assert!(
|
||||
!has_gateway_reason(&gateway, PiGatewayReasonCode::InvalidHeaderValue),
|
||||
"a deferred expression must not be validated as an HTTP header value before \
|
||||
it is resolved"
|
||||
);
|
||||
assert_eq!(gateway.capability, PiGatewayCapability::Proxyable);
|
||||
let materialized = gateway.plans[0]
|
||||
.materialize(&|value: &str| (value == expression).then(|| "resolved-secret".to_string()))
|
||||
.expect("materialize resolved candidate");
|
||||
assert_eq!(
|
||||
materialized.headers[&http::HeaderName::from_static("x-tenant")],
|
||||
http::HeaderValue::from_static("resolved-secret"),
|
||||
"the resolved value is what reaches the candidate"
|
||||
);
|
||||
|
||||
// 防过度放宽:字面量(非 deferred)含头非法字符仍必须当场拒绝。
|
||||
let literal = composed_catalog(json!({
|
||||
"api": "openai-responses",
|
||||
"baseUrl": "https://openai.example/v1",
|
||||
"apiKey": "literal",
|
||||
"headers": {"x-tenant": "café"},
|
||||
"models": [{"id": "m"}]
|
||||
}));
|
||||
let gateway = assess_composition(&literal);
|
||||
assert!(
|
||||
has_gateway_reason(&gateway, PiGatewayReasonCode::InvalidHeaderValue),
|
||||
"a literal header value outside visible ASCII must still be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user