diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 344983e7a..e4171f733 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -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]] diff --git a/backend/crates/gateway-api/Cargo.toml b/backend/crates/gateway-api/Cargo.toml index 3d98a1e41..1b77e5779 100644 --- a/backend/crates/gateway-api/Cargo.toml +++ b/backend/crates/gateway-api/Cargo.toml @@ -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" diff --git a/backend/crates/gateway-api/src/openai/responses/error.rs b/backend/crates/gateway-api/src/openai/responses/error.rs index 3894beb34..feadc1cd8 100644 --- a/backend/crates/gateway-api/src/openai/responses/error.rs +++ b/backend/crates/gateway-api/src/openai/responses/error.rs @@ -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(), diff --git a/backend/crates/gateway-api/src/openai/responses/request.rs b/backend/crates/gateway-api/src/openai/responses/request.rs index 718375084..53ec3f0c8 100644 --- a/backend/crates/gateway-api/src/openai/responses/request.rs +++ b/backend/crates/gateway-api/src/openai/responses/request.rs @@ -328,7 +328,64 @@ pub fn decode_request_with_headers( body: &[u8], headers: &HeaderMap, ) -> Result { - 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, 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(mut reader: R) -> Result, 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( diff --git a/backend/crates/gateway-api/tests/openai/responses/mod.rs b/backend/crates/gateway-api/tests/openai/responses/mod.rs index b4b003e65..69483a661 100644 --- a/backend/crates/gateway-api/tests/openai/responses/mod.rs +++ b/backend/crates/gateway-api/tests/openai/responses/mod.rs @@ -715,3 +715,89 @@ fn transparent_encoder_should_only_require_wire_terminal_for_buffered_conversion ResponseEncodeError::MissingWireTerminal ); } + +fn gzip_bytes(data: &[u8]) -> Vec { + 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 { + 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); +}