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:
zhangwentian
2026-09-11 23:21:45 +08:00
parent d5e9360ed7
commit 2390783900
5 changed files with 171 additions and 2 deletions
+2
View File
@@ -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]]
+2
View File
@@ -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);
}