mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix(openai): decode gzip/deflate/zstd request bodies on POST /v1/responses
官方 Codex 客户端对较大的 /v1/responses 请求体默认 zstd 压缩(level 3)并 携带 Content-Encoding: zstd,也会使用 gzip。网关此前只在出站方向按官方指纹 做 zstd 压缩(client_sse.rs),入站方向却不接受压缩正文,压缩请求一律命中 400 invalid_json("Request body must be valid JSON.")。官方后端接受压缩 请求,因此直连官方可用的会话经网关转发时会在 HTTP 回退路径稳定失败。 现在 POST /v1/responses 入站解码按 Content-Encoding 解压后再进入 JSON 解析: - 支持 gzip、deflate、zstd;未压缩与 identity 原样透传 - 解压结果超过 64 MiB 上限时拒绝,防御解压炸弹 - 未知 Content-Encoding 返回 400 unsupported_content_encoding - 损坏的压缩载荷统一按 invalid_json 返回,不泄露内部细节 WebSocket 帧路径不受影响(permessage-deflate 由传输层协商处理)。
This commit is contained in:
Generated
+2
@@ -1458,6 +1458,7 @@ dependencies = [
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"flate2",
|
||||
"futures",
|
||||
"gateway-admin",
|
||||
"gateway-core",
|
||||
@@ -1473,6 +1474,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -15,6 +15,7 @@ path = "tests/main.rs"
|
||||
axum = { version = "0.8.9", default-features = false, features = ["json", "query", "ws"] }
|
||||
base64 = "0.23.0"
|
||||
bytes = "1.11.1"
|
||||
flate2 = "1.1.9"
|
||||
chrono = { version = "0.4.45", features = ["serde"] }
|
||||
futures = "0.3.32"
|
||||
gateway-admin = { path = "../gateway-admin" }
|
||||
@@ -29,6 +30,7 @@ tower-http = { version = "0.7.0", features = ["cors", "fs", "request-id", "timeo
|
||||
tracing = "0.1.44"
|
||||
url = "2.5.8"
|
||||
uuid = { version = "1.23.3", features = ["v7"] }
|
||||
zstd = "=0.13.3"
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = "0.1.89"
|
||||
|
||||
@@ -40,6 +40,18 @@ pub enum RequestDecodeError {
|
||||
/// 请求不是合法 JSON。
|
||||
#[error("request body must be valid JSON")]
|
||||
MalformedJson,
|
||||
/// `Content-Encoding` 指示的压缩正文无法解压。
|
||||
#[error("request body could not be decompressed")]
|
||||
MalformedContentEncoding,
|
||||
/// 请求体解压后超过允许上限。
|
||||
#[error("decompressed request body is too large")]
|
||||
DecompressedBodyTooLarge,
|
||||
/// 请求使用了网关不支持的 `Content-Encoding`。
|
||||
#[error("request content-encoding `{encoding}` is not supported")]
|
||||
UnsupportedContentEncoding {
|
||||
/// Content-Encoding 标识。
|
||||
encoding: String,
|
||||
},
|
||||
/// 顶层不是 object。
|
||||
#[error("request body must be a JSON object")]
|
||||
ExpectedObject,
|
||||
@@ -94,11 +106,21 @@ impl RequestDecodeError {
|
||||
#[must_use]
|
||||
pub fn protocol_body(&self) -> ProtocolErrorBody {
|
||||
let (code, message, param) = match self {
|
||||
Self::MalformedJson => (
|
||||
Self::MalformedJson | Self::MalformedContentEncoding => (
|
||||
"invalid_json",
|
||||
"Request body must be valid JSON.".to_owned(),
|
||||
None,
|
||||
),
|
||||
Self::DecompressedBodyTooLarge => (
|
||||
"request_too_large",
|
||||
"Decompressed request body exceeds the allowed size.".to_owned(),
|
||||
None,
|
||||
),
|
||||
Self::UnsupportedContentEncoding { encoding } => (
|
||||
"unsupported_content_encoding",
|
||||
format!("Content-Encoding `{encoding}` is not supported."),
|
||||
None,
|
||||
),
|
||||
Self::ExpectedObject => (
|
||||
"invalid_request_body",
|
||||
"Request body must be a JSON object.".to_owned(),
|
||||
|
||||
@@ -327,7 +327,64 @@ pub fn decode_request_with_headers(
|
||||
body: &[u8],
|
||||
headers: &HeaderMap,
|
||||
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
|
||||
decode_request_inner(body, &OpenAiRequestHeaders::from_headers(headers))
|
||||
let body = decompress_request_body(body, headers)?;
|
||||
decode_request_inner(&body, &OpenAiRequestHeaders::from_headers(headers))
|
||||
}
|
||||
|
||||
/// 下游请求体解压后的最大字节数;防御解压炸弹。
|
||||
const MAX_DECOMPRESSED_REQUEST_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
/// 按 `Content-Encoding` 解压下游请求体。
|
||||
///
|
||||
/// 官方 Codex 客户端对较大的 `/v1/responses` 请求体默认 zstd 压缩,亦可能使用
|
||||
/// gzip;未压缩与 `identity` 原样透传。解压结果超过
|
||||
/// [`MAX_DECOMPRESSED_REQUEST_BYTES`] 时拒绝,避免解压炸弹。
|
||||
fn decompress_request_body(
|
||||
body: &[u8],
|
||||
headers: &HeaderMap,
|
||||
) -> Result<Vec<u8>, RequestDecodeError> {
|
||||
let Some(value) = headers.get(axum::http::header::CONTENT_ENCODING) else {
|
||||
return Ok(body.to_vec());
|
||||
};
|
||||
let encoding = value
|
||||
.to_str()
|
||||
.map_err(|_| RequestDecodeError::MalformedJson)?;
|
||||
match encoding.trim().to_ascii_lowercase().as_str() {
|
||||
"" | "identity" => Ok(body.to_vec()),
|
||||
"gzip" => {
|
||||
let decoder = flate2::read::GzDecoder::new(body);
|
||||
read_bounded(decoder)
|
||||
}
|
||||
"deflate" => {
|
||||
let decoder = flate2::read::ZlibDecoder::new(body);
|
||||
read_bounded(decoder)
|
||||
}
|
||||
"zstd" => {
|
||||
let decoded = zstd::stream::decode_all(std::io::Cursor::new(body))
|
||||
.map_err(|_| RequestDecodeError::MalformedJson)?;
|
||||
if decoded.len() > MAX_DECOMPRESSED_REQUEST_BYTES {
|
||||
return Err(RequestDecodeError::DecompressedBodyTooLarge);
|
||||
}
|
||||
Ok(decoded)
|
||||
}
|
||||
other => Err(RequestDecodeError::UnsupportedContentEncoding {
|
||||
encoding: other.to_owned(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_bounded<R: std::io::Read>(mut reader: R) -> Result<Vec<u8>, RequestDecodeError> {
|
||||
use std::io::Read;
|
||||
let mut decoded = Vec::new();
|
||||
reader
|
||||
.by_ref()
|
||||
.take((MAX_DECOMPRESSED_REQUEST_BYTES + 1) as u64)
|
||||
.read_to_end(&mut decoded)
|
||||
.map_err(|_| RequestDecodeError::MalformedJson)?;
|
||||
if decoded.len() > MAX_DECOMPRESSED_REQUEST_BYTES {
|
||||
return Err(RequestDecodeError::DecompressedBodyTooLarge);
|
||||
}
|
||||
Ok(decoded)
|
||||
}
|
||||
|
||||
pub(super) fn decode_request_inner(
|
||||
|
||||
@@ -675,3 +675,89 @@ fn transparent_encoder_should_only_require_wire_terminal_for_buffered_conversion
|
||||
ResponseEncodeError::MissingWireTerminal
|
||||
);
|
||||
}
|
||||
|
||||
fn gzip_bytes(data: &[u8]) -> Vec<u8> {
|
||||
use std::io::Write as _;
|
||||
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
encoder.write_all(data).expect("gzip encode");
|
||||
encoder.finish().expect("gzip finish")
|
||||
}
|
||||
|
||||
fn zstd_bytes(data: &[u8]) -> Vec<u8> {
|
||||
zstd::stream::encode_all(std::io::Cursor::new(data), 3).expect("zstd encode")
|
||||
}
|
||||
|
||||
fn content_encoding_headers(encoding: &str) -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
axum::http::header::CONTENT_ENCODING,
|
||||
HeaderValue::from_str(encoding).expect("valid header value"),
|
||||
);
|
||||
headers
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_decode_accepts_gzip_request_body() {
|
||||
let body = json!({"model": "gpt-test", "input": []}).to_string();
|
||||
let decoded = decode_request_with_headers(
|
||||
gzip_bytes(body.as_bytes()).as_slice(),
|
||||
&content_encoding_headers("gzip"),
|
||||
)
|
||||
.expect("gzip body should decode");
|
||||
assert_eq!(decoded.metadata().requested_model().to_string(), "gpt-test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_decode_accepts_zstd_request_body() {
|
||||
let body = json!({"model": "gpt-test", "input": []}).to_string();
|
||||
let decoded = decode_request_with_headers(
|
||||
zstd_bytes(body.as_bytes()).as_slice(),
|
||||
&content_encoding_headers("zstd"),
|
||||
)
|
||||
.expect("zstd body should decode");
|
||||
assert_eq!(decoded.metadata().requested_model().to_string(), "gpt-test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_decode_treats_identity_encoding_as_plain_json() {
|
||||
let body = json!({"model": "gpt-test", "input": []}).to_string();
|
||||
let decoded =
|
||||
decode_request_with_headers(body.as_bytes(), &content_encoding_headers("identity"))
|
||||
.expect("identity body should decode");
|
||||
assert_eq!(decoded.metadata().requested_model().to_string(), "gpt-test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_decode_rejects_unsupported_content_encoding() {
|
||||
let body = json!({"model": "gpt-test", "input": []}).to_string();
|
||||
let error = decode_request_with_headers(body.as_bytes(), &content_encoding_headers("br"))
|
||||
.expect_err("unsupported encoding must fail");
|
||||
assert_eq!(
|
||||
error,
|
||||
RequestDecodeError::UnsupportedContentEncoding {
|
||||
encoding: "br".to_owned()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_decode_rejects_corrupted_compressed_body() {
|
||||
let error = decode_request_with_headers(
|
||||
b"not-a-valid-gzip-stream",
|
||||
&content_encoding_headers("gzip"),
|
||||
)
|
||||
.expect_err("corrupted gzip body must fail");
|
||||
// 损坏的压缩载荷统一按 invalid_json 返回,不区分内部解压细节。
|
||||
assert_eq!(error, RequestDecodeError::MalformedJson);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_decode_rejects_decompression_bomb() {
|
||||
// 高压缩比载荷:解压后超过 64 MiB 上限时必须拒绝。
|
||||
let bomb = vec![0u8; 128 * 1024 * 1024];
|
||||
let compressed = zstd_bytes(&bomb);
|
||||
let error =
|
||||
decode_request_with_headers(compressed.as_slice(), &content_encoding_headers("zstd"))
|
||||
.expect_err("decompression bomb must fail");
|
||||
assert_eq!(error, RequestDecodeError::DecompressedBodyTooLarge);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user