diff --git a/backend/crates/gateway-api/src/openai/responses/request.rs b/backend/crates/gateway-api/src/openai/responses/request.rs index 43574a2d0..718375084 100644 --- a/backend/crates/gateway-api/src/openai/responses/request.rs +++ b/backend/crates/gateway-api/src/openai/responses/request.rs @@ -7,6 +7,7 @@ use base64::{Engine as _, engine::general_purpose::STANDARD}; use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload, ProviderSessionState}; use gateway_protocol::openai::{ X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER, X_OPENAI_MEMGEN_REQUEST_HEADER, + is_transport_managed_request_header, }; use serde_json::{Map, Value}; @@ -458,7 +459,7 @@ fn passthrough_header_name(name: &str, connection_headers: &[String]) -> bool { if connection_headers .iter() .any(|connection_header| connection_header.eq_ignore_ascii_case(name)) - || name.starts_with("sec-websocket-") + || is_transport_managed_request_header(name) || name.starts_with("x-grok-") || name.starts_with("x-xai-") { @@ -467,30 +468,8 @@ fn passthrough_header_name(name: &str, connection_headers: &[String]) -> bool { !matches!( name, - // 逐跳头、上游 authority 和重序列化后的实体长度由 transport 重建。 - "connection" - | "keep-alive" - | "proxy-connection" - | "proxy-authenticate" - | "proxy-authorization" - | "te" - | "trailer" - | "transfer-encoding" - | "upgrade" - | "host" - | "content-length" - // 下游连接诊断事实只用于本地观测,不能伪装成 OpenAI 请求事实。 - | "forwarded" - | "x-forwarded-for" - | "x-forwarded-host" - | "x-forwarded-proto" - | "x-forwarded-port" - | "x-real-ip" - | "true-client-ip" - | "cf-connecting-ip" - | "x-request-id" - // 下游鉴权和账号 cookie 绝不能成为上游账号身份。 - | "authorization" + // 下游鉴权和账号 cookie 绝不能成为上游账号身份。 + "authorization" | "x-api-key" // Codex 的服务端托管认证标记只用于客户端能力判断,不代表上游身份。 | "x-openai-actor-authorization" diff --git a/backend/crates/gateway-api/tests/openai/responses/mod.rs b/backend/crates/gateway-api/tests/openai/responses/mod.rs index 41f316349..b4b003e65 100644 --- a/backend/crates/gateway-api/tests/openai/responses/mod.rs +++ b/backend/crates/gateway-api/tests/openai/responses/mod.rs @@ -369,6 +369,46 @@ fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() { } } +#[test] +fn decoder_should_exclude_downstream_transport_headers_from_opaque_context() { + let mut headers = HeaderMap::new(); + for name in [ + "cf-visitor", + "cf-connecting-ip", + "cf-connecting-ipv6", + "cf-pseudo-ipv4", + "cf-ray", + "cf-ipcountry", + "cf-warp-tag-id", + "cf-worker", + "cf-ew-via", + "cdn-loop", + "via", + "forwarded", + "x-forwarded-for", + "x-forwarded-prefix", + "accept-encoding", + "content-encoding", + ] { + headers.insert(name, HeaderValue::from_static("downstream-only")); + } + headers.insert( + "CF-Visitor", + HeaderValue::from_static(r#"{"scheme":"https"}"#), + ); + headers.insert("accept-encoding", HeaderValue::from_static("br, gzip")); + headers.insert("x-openai-future-mode", HeaderValue::from_static("keep")); + + let decoded = + decode_request_with_headers(br#"{"model":"smart-code","input":"hello"}"#, &headers) + .expect("decode request behind a reverse proxy"); + + assert_eq!( + openai_protocol_context(&decoded).get("opaque_request_headers"), + Some(&json!([["x-openai-future-mode", STANDARD.encode(b"keep")]])), + ); +} + #[test] fn decoder_should_preserve_unknown_nested_values_without_debug_disclosure() { let secret = "nested-private-value"; diff --git a/backend/crates/gateway-api/tests/openai/responses/websocket/protocol.rs b/backend/crates/gateway-api/tests/openai/responses/websocket/protocol.rs index 05b76e066..69618d6b6 100644 --- a/backend/crates/gateway-api/tests/openai/responses/websocket/protocol.rs +++ b/backend/crates/gateway-api/tests/openai/responses/websocket/protocol.rs @@ -1,8 +1,53 @@ +use axum::http::{HeaderMap, HeaderValue}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use gateway_api::openai::responses::{OpenAiRequestHeaders, decode_response_create_with_context}; use gateway_core::operation::Operation; use serde_json::json; use super::decode_response_create; +#[test] +fn response_create_should_exclude_cloudflare_headers_on_every_frame() { + let mut headers = HeaderMap::new(); + for (name, value) in [ + ("CF-Visitor", r#"{"scheme":"https"}"#), + ("cdn-loop", "cloudflare; loops=1"), + ("cf-warp-tag-id", "downstream-warp"), + ("cf-ipcountry", "US"), + ("cf-worker", "worker.example"), + ("cf-ew-via", "15"), + ("cf-future-proxy-field", "downstream-only"), + ] { + headers.insert(name, HeaderValue::from_static(value)); + } + headers.append("x-openai-future-mode", HeaderValue::from_static("first")); + headers.append("x-openai-future-mode", HeaderValue::from_static("second")); + let request_headers = OpenAiRequestHeaders::from_headers(&headers); + + // 连接级头会用于同一 WebSocket 的每一帧,后续帧也不能恢复下游链路元数据。 + for input in ["hello", "continue"] { + let decoded = decode_response_create_with_context( + &json!({"type": "response.create", "model": "smart-code", "input": input}).to_string(), + &request_headers, + ) + .expect("decode response.create behind Cloudflare"); + let Operation::Generate(request) = decoded.operation() else { + panic!("Responses must map to Generate"); + }; + assert_eq!( + request + .protocol_payload() + .context() + .get("opaque_request_headers"), + Some(&json!([ + ["x-openai-future-mode", STANDARD.encode(b"first")], + ["x-openai-future-mode", STANDARD.encode(b"second")], + ])), + ); + assert_eq!(request.protocol_payload().body()["input"], input); + } +} + #[test] fn latest_official_codex_response_create_fixture_decodes_unchanged() { // Audited against openai/codex main 94cbbddafc1776d5e377bca1b05932c697e82238. diff --git a/backend/crates/gateway-protocol/src/openai/headers.rs b/backend/crates/gateway-protocol/src/openai/headers.rs new file mode 100644 index 000000000..aa8954716 --- /dev/null +++ b/backend/crates/gateway-protocol/src/openai/headers.rs @@ -0,0 +1,36 @@ +//! OpenAI 请求头的业务协议与传输边界。 + +/// 判断小写请求头是否属于传输层管理的字段,不得作为业务扩展头透传。 +/// +/// API 入站和 Provider 编码共用此分类;`Connection` 动态声明的逐跳头由入站额外剥离。 +/// 只用于请求方向,不影响上游响应中的代理诊断信息。 +#[must_use] +pub fn is_transport_managed_request_header(name: &str) -> bool { + // 代理命名空间描述的是下游链路;包括未知扩展也不能冒充上游连接事实。 + name.starts_with("cf-") + || name.starts_with("x-forwarded-") + || name.starts_with("sec-websocket-") + || matches!( + name, + "connection" + | "keep-alive" + | "proxy-connection" + | "proxy-authenticate" + | "proxy-authorization" + | "te" + | "trailer" + | "transfer-encoding" + | "upgrade" + | "host" + | "content-length" + // 请求实体与响应压缩能力属于各段 transport,不能继承下游协商。 + | "content-encoding" + | "accept-encoding" + | "forwarded" + | "via" + | "cdn-loop" + | "x-real-ip" + | "true-client-ip" + | "x-request-id" + ) +} diff --git a/backend/crates/gateway-protocol/src/openai/mod.rs b/backend/crates/gateway-protocol/src/openai/mod.rs index e16623724..b5370c886 100644 --- a/backend/crates/gateway-protocol/src/openai/mod.rs +++ b/backend/crates/gateway-protocol/src/openai/mod.rs @@ -13,6 +13,7 @@ pub const X_OPENAI_MEMGEN_REQUEST_HEADER: &str = "x-openai-memgen-request"; pub mod codex; /// OpenAI/Codex 事件语义、用量与限流字段编解码。 pub mod events; +mod headers; /// Server-Sent Events 帧的解析与编码。 pub mod sse; @@ -20,3 +21,4 @@ pub use codex::{ CodexResponsesRequestSemantics, codex_responses_request_semantics, codex_responses_request_semantics_with_turn_metadata, codex_session_id, codex_thread_id, }; +pub use headers::is_transport_managed_request_header; diff --git a/backend/crates/gateway-protocol/tests/openai/headers.rs b/backend/crates/gateway-protocol/tests/openai/headers.rs new file mode 100644 index 000000000..57635d304 --- /dev/null +++ b/backend/crates/gateway-protocol/tests/openai/headers.rs @@ -0,0 +1,68 @@ +use gateway_protocol::openai::is_transport_managed_request_header; + +#[test] +fn transport_headers_should_include_proxy_namespaces_and_compression() { + for name in [ + "cf-visitor", + "cf-connecting-ip", + "cf-connecting-ipv6", + "cf-pseudo-ipv4", + "cf-ray", + "cf-ipcountry", + "cf-warp-tag-id", + "cf-worker", + "cf-ew-via", + "cf-future-proxy-field", + "cdn-loop", + "via", + "forwarded", + "x-forwarded-for", + "x-forwarded-host", + "x-forwarded-proto", + "x-forwarded-port", + "x-forwarded-prefix", + "x-forwarded-future-field", + "x-real-ip", + "true-client-ip", + "x-request-id", + "accept-encoding", + "content-encoding", + "content-length", + "host", + "connection", + "keep-alive", + "proxy-connection", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "sec-websocket-key", + "sec-websocket-extensions", + ] { + assert!(is_transport_managed_request_header(name), "missing {name}"); + } +} + +#[test] +fn transport_headers_should_leave_business_extensions_to_the_protocol_owner() { + for name in [ + "x-openai-future-mode", + "x-custom-extension", + "x-client-request-id", + "session-id", + "thread-id", + "x-codex-turn-state", + "x-codex-beta-features", + "openai-beta", + "accept", + "content-type", + "x-cf-business-field", + ] { + assert!( + !is_transport_managed_request_header(name), + "unexpected {name}" + ); + } +} diff --git a/backend/crates/gateway-protocol/tests/openai/mod.rs b/backend/crates/gateway-protocol/tests/openai/mod.rs index ebfb9ccfd..611686b26 100644 --- a/backend/crates/gateway-protocol/tests/openai/mod.rs +++ b/backend/crates/gateway-protocol/tests/openai/mod.rs @@ -1,3 +1,4 @@ mod codex; mod events; +mod headers; mod sse; diff --git a/backend/crates/providers/openai/src/transport/request.rs b/backend/crates/providers/openai/src/transport/request.rs index 544498696..275b5bdfe 100644 --- a/backend/crates/providers/openai/src/transport/request.rs +++ b/backend/crates/providers/openai/src/transport/request.rs @@ -4,7 +4,9 @@ use std::io; use base64::{Engine as _, engine::general_purpose::STANDARD}; use gateway_core::operation::GenerateRequest; -use gateway_protocol::openai::WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY; +use gateway_protocol::openai::{ + WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY, is_transport_managed_request_header, +}; use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; use serde::Serialize as _; use serde_json::{Map, Value}; @@ -619,32 +621,12 @@ fn decode_passthrough_headers(context: &Map) -> HeaderMap { } fn provider_managed_header(name: &str) -> bool { - name.starts_with("sec-websocket-") + is_transport_managed_request_header(name) || name.starts_with("x-grok-") || name.starts_with("x-xai-") || matches!( name, - "connection" - | "keep-alive" - | "proxy-connection" - | "proxy-authenticate" - | "proxy-authorization" - | "te" - | "trailer" - | "transfer-encoding" - | "upgrade" - | "host" - | "content-length" - | "forwarded" - | "x-forwarded-for" - | "x-forwarded-host" - | "x-forwarded-proto" - | "x-forwarded-port" - | "x-real-ip" - | "true-client-ip" - | "cf-connecting-ip" - | "x-request-id" - | "authorization" + "authorization" | "x-api-key" | "x-openai-actor-authorization" | "cookie" diff --git a/backend/crates/providers/openai/tests/transport/headers.rs b/backend/crates/providers/openai/tests/transport/headers.rs index 9602d7c47..6ef3dbea1 100644 --- a/backend/crates/providers/openai/tests/transport/headers.rs +++ b/backend/crates/providers/openai/tests/transport/headers.rs @@ -7,16 +7,27 @@ use tokio_tungstenite::tungstenite::http::HeaderMap as WsHeaderMap; use super::*; +const DOWNSTREAM_TRANSPORT_HEADERS: &[(&str, &str)] = &[ + ("CF-Visitor", r#"{"scheme":"https"}"#), + ("cf-connecting-ip", "192.0.2.1"), + ("cf-connecting-ipv6", "2001:db8::1"), + ("cf-pseudo-ipv4", "240.0.0.1"), + ("cf-ray", "downstream-ray"), + ("cf-ipcountry", "US"), + ("cf-warp-tag-id", "downstream-warp"), + ("cf-worker", "worker.example"), + ("cf-ew-via", "15"), + ("cdn-loop", "cloudflare; loops=1"), + ("via", "1.1 downstream-proxy"), + ("forwarded", "for=192.0.2.1;proto=https"), + ("x-forwarded-for", "192.0.2.1"), + ("x-forwarded-prefix", "/gateway"), + ("accept-encoding", "br, gzip"), + ("content-encoding", "downstream-encoding"), +]; + fn request_with_opaque_headers(use_websocket: bool) -> CodexResponsesRequest { - let payload = ProtocolPayload::json_object( - "openai", - json!({"model": "gpt-test", "input": "hello"}) - .as_object() - .expect("request object") - .clone(), - ) - .expect("OpenAI payload") - .with_context(Map::from_iter([ + let mut context = Map::from_iter([ ( "opaque_request_headers".to_owned(), json!([ @@ -61,7 +72,25 @@ fn request_with_opaque_headers(use_websocket: bool) -> CodexResponsesRequest { ]), ), ("turn_state".to_owned(), json!("typed-turn-state")), - ])); + ]); + context + .get_mut("opaque_request_headers") + .and_then(Value::as_array_mut) + .expect("opaque headers") + .extend( + DOWNSTREAM_TRANSPORT_HEADERS + .iter() + .map(|(name, value)| json!([name, STANDARD.encode(value.as_bytes())])), + ); + let payload = ProtocolPayload::json_object( + "openai", + json!({"model": "gpt-test", "input": "hello"}) + .as_object() + .expect("request object") + .clone(), + ) + .expect("OpenAI payload") + .with_context(context); let mut request = encode_generate_request( &GenerateRequest::from_protocol_payload(payload), "gpt-routed", @@ -352,7 +381,7 @@ async fn backend_websocket_should_forward_context_headers_and_preserve_payload_f } #[tokio::test] -async fn backend_http_should_restore_opaque_multivalue_header_bytes_and_lease_identity() { +async fn backend_http_should_preserve_business_headers_without_downstream_transport_headers() { let listener = TcpListener::bind("127.0.0.1:0") .await .expect("bind opaque HTTP server"); @@ -394,6 +423,17 @@ async fn backend_http_should_restore_opaque_multivalue_header_bytes_and_lease_id .expect("opaque HTTP response"); let raw = server.await.expect("opaque HTTP server task"); + for &(name, _) in DOWNSTREAM_TRANSPORT_HEADERS { + // HTTP 正文由 transport 重编码为 zstd,不能沿用下游 Content-Encoding。 + if name == "content-encoding" { + assert_eq!(raw_header_values(&raw, name), vec![b"zstd".to_vec()]); + } else { + assert!( + raw_header_values(&raw, name).is_empty(), + "unexpected {name}" + ); + } + } assert_eq!( raw_header_values(&raw, "x-openai-future-mode"), vec![b"future-ascii".to_vec(), b"\x80\xff".to_vec()] @@ -450,7 +490,7 @@ async fn backend_http_should_restore_opaque_multivalue_header_bytes_and_lease_id } #[tokio::test] -async fn backend_websocket_should_drop_only_unrepresentable_opaque_header_values() { +async fn backend_websocket_should_preserve_business_headers_without_downstream_transport_headers() { let received = Arc::new(Mutex::new(WsHeaderMap::new())); let listener = TcpListener::bind("127.0.0.1:0") .await @@ -520,6 +560,17 @@ async fn backend_websocket_should_drop_only_unrepresentable_opaque_header_values .collect::>() }; + for (name, _) in DOWNSTREAM_TRANSPORT_HEADERS { + assert!(values(name).is_empty(), "unexpected {name}"); + } + assert!( + received + .get("sec-websocket-extensions") + .expect("upstream WebSocket compression negotiation") + .to_str() + .expect("extension value") + .contains("permessage-deflate") + ); assert_eq!( values("x-openai-future-mode"), vec![b"future-ascii".to_vec()] diff --git a/docs/api.md b/docs/api.md index 9bc3dddd3..196f92c35 100644 --- a/docs/api.md +++ b/docs/api.md @@ -129,6 +129,11 @@ Responses、Images 和 standalone Search HTTP body、WebSocket message 和 frame Codex 的 review 等子代理请求仍使用 `/v1/responses`,并通过 `x-openai-subagent` 请求头携带子代理类型; 网关不提供独立的子代理请求路径。 +Responses 不透传下游的逐跳头、反代元数据(如 `cf-*`、`x-forwarded-*`、`forwarded`、`via`、 +`cdn-loop`)以及 `Accept-Encoding` / `Content-Encoding`。链路元数据和编解码能力 +由各段传输层独立管理;其余业务扩展头继续透传,不使用固定业务头白名单。 +此规则同时适用于上游 HTTP 和 WebSocket,不影响上游响应的 `cf-ray` 等诊断信息。 + Responses WebSocket 仅接受文本 `response.create`,同一连接串行执行。当前响应期间收到的后续业务帧 留在有界接收队列中,待当前响应完成终结和写出后再逐条校验、准入与执行,不因请求提前到达而断开。 这对齐 Codex 客户端 `stream_request` 持锁至本轮结束的串行行为,不表示支持额外控制消息类型。 diff --git a/docs/architecture.md b/docs/architecture.md index 3e4184c13..2f49f0399 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -174,6 +174,9 @@ Core 只理解 `Operation`、能力要求、Provider 候选、稳定错误和 ca - OpenAI 是透明边界。Responses 请求保留未知字段和字段顺序;SSE、WebSocket、Images 与 standalone Search 的业务正文按原始字节转发。canonical facts 从同一数据旁路提取,只用于路由、观测和计费。 +- Responses 的业务扩展头保留原始多值字节;传输与反代请求头分类由 `gateway-protocol` 统一定义, + API 入站与 OpenAI Provider 编码共同使用。下游链路元数据和压缩协商不跨越该边界, + 上游认证、请求画像与传输字段仍由 Provider 生成;响应方向的诊断头不受请求过滤规则影响。 - xAI 是翻译边界。Provider 把 Grok wire 转换为 Responses wire;上游结构化错误的 message/code/type 可以透出,但账号指纹会先脱敏。 - response ID 是不透明 UTF-8 bytes,不假设 UUID、固定长度或跨 Provider 可复用。