fix(pi): validate deferred headers after resolution

This commit is contained in:
SaladDay
2026-08-02 12:05:25 +00:00
parent 9b5a146323
commit 2f856b0ffb
2 changed files with 159 additions and 40 deletions
+49 -2
View File
@@ -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"
);
}