mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix(openai): strip downstream proxy headers from upstream requests
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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()]
|
||||
|
||||
@@ -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` 持锁至本轮结束的串行行为,不表示支持额外控制消息类型。
|
||||
|
||||
@@ -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 可复用。
|
||||
|
||||
Reference in New Issue
Block a user