fix(openai): strip downstream proxy headers from upstream requests

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
xbz123
2026-09-11 17:55:11 +02:00
co-authored by Codex
parent d5e9360ed7
commit 757abdb4d1
11 changed files with 272 additions and 60 deletions
@@ -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"
@@ -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";
@@ -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.
@@ -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"
)
}
@@ -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;
@@ -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}"
);
}
}
@@ -1,3 +1,4 @@
mod codex;
mod events;
mod headers;
mod sse;
@@ -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<String, Value>) -> 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"
@@ -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::<Vec<_>>()
};
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()]
+5
View File
@@ -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` 持锁至本轮结束的串行行为,不表示支持额外控制消息类型。
+3
View File
@@ -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 可复用。