From be2ee7f3b8b3607e278e1b53ac22cc6c28506864 Mon Sep 17 00:00:00 2001 From: Zyy Date: Sat, 12 Sep 2026 00:10:23 +0800 Subject: [PATCH] fix(openai): bound request decompression and validate encodings Stream all supported encodings through a bounded decoder, cap buffer growth and the zstd window, and consume every gzip member. Borrow uncompressed bodies, reject stacked encodings, and remove the unused error variant. Add decoding and HTTP boundary regressions, preserve downstream transport-header isolation, and document the ingress compression contract. Validation: 384 API, protocol, and application tests, Rustfmt, and strict workspace Clippy passed. Twenty real HTTP requests passed with synthetic upstream execution. An 8,210-byte zstd probe now stops at the 64 MiB output limit, with a largest Rust allocation of 64 MiB instead of 256 MiB. Official Codex client fallback and real upstream execution were not exercised. Co-authored-by: Codex --- backend/crates/gateway-api/Cargo.toml | 2 +- .../gateway-api/src/openai/responses/error.rs | 5 +- .../src/openai/responses/request.rs | 82 +++++---- .../crates/gateway-api/tests/architecture.rs | 1 + .../tests/openai/responses/http.rs | 99 +++++++++- .../gateway-api/tests/openai/responses/mod.rs | 90 +-------- .../tests/openai/responses/request.rs | 174 ++++++++++++++++++ docs/api.md | 13 +- 8 files changed, 331 insertions(+), 135 deletions(-) create mode 100644 backend/crates/gateway-api/tests/openai/responses/request.rs diff --git a/backend/crates/gateway-api/Cargo.toml b/backend/crates/gateway-api/Cargo.toml index 1b77e5779..76348eb3d 100644 --- a/backend/crates/gateway-api/Cargo.toml +++ b/backend/crates/gateway-api/Cargo.toml @@ -15,8 +15,8 @@ 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"] } +flate2 = "1.1.9" futures = "0.3.32" gateway-admin = { path = "../gateway-admin" } gateway-core = { path = "../gateway-core" } diff --git a/backend/crates/gateway-api/src/openai/responses/error.rs b/backend/crates/gateway-api/src/openai/responses/error.rs index feadc1cd8..a6998519b 100644 --- a/backend/crates/gateway-api/src/openai/responses/error.rs +++ b/backend/crates/gateway-api/src/openai/responses/error.rs @@ -40,9 +40,6 @@ 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, @@ -106,7 +103,7 @@ impl RequestDecodeError { #[must_use] pub fn protocol_body(&self) -> ProtocolErrorBody { let (code, message, param) = match self { - Self::MalformedJson | Self::MalformedContentEncoding => ( + Self::MalformedJson => ( "invalid_json", "Request body must be valid JSON.".to_owned(), None, diff --git a/backend/crates/gateway-api/src/openai/responses/request.rs b/backend/crates/gateway-api/src/openai/responses/request.rs index 53ec3f0c8..1f5b32847 100644 --- a/backend/crates/gateway-api/src/openai/responses/request.rs +++ b/backend/crates/gateway-api/src/openai/responses/request.rs @@ -1,6 +1,6 @@ //! OpenAI Responses JSON 到 Core 路由事实与不透明 wire payload 的单一解码边界。 -use std::{fmt, net::IpAddr}; +use std::{borrow::Cow, fmt, net::IpAddr}; use axum::http::HeaderMap; use base64::{Engine as _, engine::general_purpose::STANDARD}; @@ -323,7 +323,7 @@ impl fmt::Debug for DecodedResponsesRequest { /// /// # Errors /// -/// JSON 非法、顶层不是 object,或网关必须解释的路由字段无效时返回安全错误。 +/// 编码不支持、压缩正文损坏或超限、JSON 非法,或路由字段无效时返回安全错误。 pub fn decode_request_with_headers( body: &[u8], headers: &HeaderMap, @@ -337,36 +337,40 @@ 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], +/// 未压缩与 `identity` 借用原始正文;压缩正文在读取过程中限制展开大小, +/// 避免先完整分配再检查。只接受单一编码,重复头和叠加编码不能只解释第一项。 +fn decompress_request_body<'a>( + body: &'a [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)?; +) -> Result, RequestDecodeError> { + let encoding = headers + .get_all(axum::http::header::CONTENT_ENCODING) + .iter() + .map(|value| { + value + .to_str() + .map_err(|_| RequestDecodeError::MalformedJson) + }) + .collect::, _>>()? + .join(","); match encoding.trim().to_ascii_lowercase().as_str() { - "" | "identity" => Ok(body.to_vec()), + "" | "identity" => Ok(Cow::Borrowed(body)), "gzip" => { - let decoder = flate2::read::GzDecoder::new(body); - read_bounded(decoder) + let decoder = flate2::read::MultiGzDecoder::new(body); + read_bounded(decoder).map(Cow::Owned) } "deflate" => { let decoder = flate2::read::ZlibDecoder::new(body); - read_bounded(decoder) + read_bounded(decoder).map(Cow::Owned) } "zstd" => { - let decoded = zstd::stream::decode_all(std::io::Cursor::new(body)) + let mut decoder = zstd::stream::read::Decoder::with_buffer(body) .map_err(|_| RequestDecodeError::MalformedJson)?; - if decoded.len() > MAX_DECOMPRESSED_REQUEST_BYTES { - return Err(RequestDecodeError::DecompressedBodyTooLarge); - } - Ok(decoded) + // 输出有界之外,也限制压缩帧声明的回溯窗口,防止解码器内部过量分配。 + decoder + .window_log_max(MAX_DECOMPRESSED_REQUEST_BYTES.ilog2()) + .map_err(|_| RequestDecodeError::MalformedJson)?; + read_bounded(decoder).map(Cow::Owned) } other => Err(RequestDecodeError::UnsupportedContentEncoding { encoding: other.to_owned(), @@ -375,17 +379,31 @@ fn decompress_request_body( } 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); + let mut chunk = [0; 8192]; + loop { + let remaining = MAX_DECOMPRESSED_REQUEST_BYTES - decoded.len(); + let read_limit = chunk.len().min(remaining + 1); + let read = reader + .read(&mut chunk[..read_limit]) + .map_err(|_| RequestDecodeError::MalformedJson)?; + if read == 0 { + return Ok(decoded); + } + if read > remaining { + return Err(RequestDecodeError::DecompressedBodyTooLarge); + } + // 默认 Vec 扩容和 read_to_end 的 EOF 探测可能突破输出上限; + // 容量增长也受相同边界约束,越界探测只使用栈上分块缓冲区。 + let required = decoded.len() + read; + if required > decoded.capacity() { + let capacity = (decoded.capacity() * 2) + .max(required) + .min(MAX_DECOMPRESSED_REQUEST_BYTES); + decoded.reserve_exact(capacity - decoded.len()); + } + decoded.extend_from_slice(&chunk[..read]); } - Ok(decoded) } pub(super) fn decode_request_inner( diff --git a/backend/crates/gateway-api/tests/architecture.rs b/backend/crates/gateway-api/tests/architecture.rs index 6a549410d..3a2927e49 100644 --- a/backend/crates/gateway-api/tests/architecture.rs +++ b/backend/crates/gateway-api/tests/architecture.rs @@ -112,6 +112,7 @@ fn test_tree_should_match_frozen_rust_mirror() { "tests/openai/endpoint.rs", "tests/openai/responses/http.rs", "tests/openai/responses/mod.rs", + "tests/openai/responses/request.rs", "tests/openai/responses/websocket/connection.rs", "tests/openai/responses/websocket/mod.rs", "tests/openai/responses/websocket/protocol.rs", diff --git a/backend/crates/gateway-api/tests/openai/responses/http.rs b/backend/crates/gateway-api/tests/openai/responses/http.rs index 775cef024..83ac98023 100644 --- a/backend/crates/gateway-api/tests/openai/responses/http.rs +++ b/backend/crates/gateway-api/tests/openai/responses/http.rs @@ -1,4 +1,5 @@ use std::collections::VecDeque; +use std::io::Read; use std::net::{IpAddr, SocketAddr}; use std::sync::{ Arc, Mutex, @@ -11,7 +12,7 @@ use axum::{ extract::connect_info::ConnectInfo, http::{ HeaderMap, HeaderValue, Request, StatusCode, - header::{AUTHORIZATION, CONTENT_TYPE}, + header::{AUTHORIZATION, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE}, }, }; use bytes::Bytes; @@ -451,9 +452,26 @@ async fn captured_client_context( async fn captured_http_request( provider_name: &str, body: Value, - mut headers: HeaderMap, + headers: HeaderMap, peer_address: Option, ) -> CapturedClientContext { + let (response, observed) = http_request_with_body( + provider_name, + body.to_string().into(), + headers, + peer_address, + ) + .await; + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + observed.expect("captured request context") +} + +async fn http_request_with_body( + provider_name: &str, + body: Bytes, + mut headers: HeaderMap, + peer_address: Option, +) -> (axum::response::Response, Option) { headers.insert( AUTHORIZATION, "Bearer sk_context_test".parse().expect("authorization"), @@ -464,7 +482,7 @@ async fn captured_http_request( client: authenticated_client_for_provider("sk_context_test", provider_name), }); let mut request = Request::post("/v1/responses") - .body(Body::from(body.to_string())) + .body(Body::from(body)) .expect("context request"); *request.headers_mut() = headers; if let Some(peer_address) = peer_address { @@ -475,12 +493,75 @@ async fn captured_http_request( .oneshot(request) .await .expect("context response"); - assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); - observed - .lock() - .expect("context capture lock") - .clone() - .expect("captured request context") + let observed = observed.lock().expect("context capture lock").clone(); + (response, observed) +} + +#[tokio::test] +async fn compressed_http_requests_should_preserve_execution_context_without_transport_headers() { + let body = json!({ + "model": "model-a", "input": "hello", "previous_response_id": "resp_previous", + "prompt_cache_key": "cache-key", "client_metadata": {"source": "test"} + }); + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + headers.insert("session_id", HeaderValue::from_static("session-test")); + let expected = captured_http_request("openai", body.clone(), headers.clone(), None).await; + for encoding in ["gzip", "deflate", "zstd"] { + let compressed = super::request::encode_body(encoding, body.to_string().as_bytes()); + let mut headers = headers.clone(); + headers.insert(CONTENT_ENCODING, HeaderValue::from_static(encoding)); + headers.insert( + CONTENT_LENGTH, + HeaderValue::from_str(&compressed.len().to_string()).expect("compressed length"), + ); + let (response, observed) = + http_request_with_body("openai", compressed.into(), headers, None).await; + // 捕获执行器在解码成功后主动返回 500;正文和业务头应与未压缩请求一致, + // 压缩编码及长度不能泄漏到已解压正文的上游协议上下文。 + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(observed.as_ref(), Some(&expected), "{encoding}"); + } +} + +#[tokio::test] +async fn invalid_compressed_http_requests_should_fail_before_execution() { + for (encoding, body, code, message) in [ + ( + "gzip", + Bytes::from_static(b"broken gzip"), + "invalid_json", + "Request body must be valid JSON.", + ), + ( + "br", + Bytes::from_static(b"{}"), + "unsupported_content_encoding", + "Content-Encoding `br` is not supported.", + ), + ( + "zstd", + super::request::encode_body("zstd", std::io::repeat(0).take(64 * 1024 * 1024 + 1)) + .into(), + "request_too_large", + "Decompressed request body exceeds the allowed size.", + ), + ] { + let headers = HeaderMap::from_iter([ + (CONTENT_ENCODING, HeaderValue::from_static(encoding)), + (CONTENT_TYPE, HeaderValue::from_static("application/json")), + ]); + let (response, observed) = http_request_with_body("openai", body, headers, None).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{encoding}"); + assert!(observed.is_none(), "invalid body must not start execution"); + let body = to_bytes(response.into_body(), 4096) + .await + .expect("error body"); + assert_eq!( + serde_json::from_slice::(&body).expect("error JSON"), + json!({"error": {"type": "invalid_request_error", "code": code, "message": message}}) + ); + } } async fn captured_http_compaction(provider_name: &str) -> CapturedClientContext { diff --git a/backend/crates/gateway-api/tests/openai/responses/mod.rs b/backend/crates/gateway-api/tests/openai/responses/mod.rs index 69483a661..95e9895b0 100644 --- a/backend/crates/gateway-api/tests/openai/responses/mod.rs +++ b/backend/crates/gateway-api/tests/openai/responses/mod.rs @@ -1,4 +1,5 @@ mod http; +mod request; mod websocket; use axum::http::{HeaderMap, HeaderValue}; @@ -388,7 +389,6 @@ fn decoder_should_exclude_downstream_transport_headers_from_opaque_context() { "x-forwarded-for", "x-forwarded-prefix", "accept-encoding", - "content-encoding", ] { headers.insert(name, HeaderValue::from_static("downstream-only")); } @@ -397,6 +397,8 @@ fn decoder_should_exclude_downstream_transport_headers_from_opaque_context() { HeaderValue::from_static(r#"{"scheme":"https"}"#), ); headers.insert("accept-encoding", HeaderValue::from_static("br, gzip")); + // 正文未压缩;入口现在消费 Content-Encoding,但仍不得向上游透传。 + headers.insert("content-encoding", HeaderValue::from_static("identity")); headers.insert("x-openai-future-mode", HeaderValue::from_static("keep")); let decoded = @@ -715,89 +717,3 @@ 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); -} diff --git a/backend/crates/gateway-api/tests/openai/responses/request.rs b/backend/crates/gateway-api/tests/openai/responses/request.rs new file mode 100644 index 000000000..f0ba6480d --- /dev/null +++ b/backend/crates/gateway-api/tests/openai/responses/request.rs @@ -0,0 +1,174 @@ +use std::io::{Read, Write, copy, repeat}; + +use axum::http::{HeaderMap, HeaderValue, header::CONTENT_ENCODING}; +use gateway_api::openai::responses::{RequestDecodeError, decode_request_with_headers}; +use serde_json::json; + +use super::openai_wire_body; + +const ENCODINGS: [&str; 3] = ["gzip", "deflate", "zstd"]; +const REQUEST: &[u8] = br#"{"model":"gpt-test","input":"hello","future_field":{"value":1}}"#; +const DECOMPRESSED_LIMIT: u64 = 64 * 1024 * 1024; + +pub(super) fn encode_body(encoding: &str, mut data: impl Read) -> Vec { + match encoding { + "gzip" => { + let mut encoder = + flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); + copy(&mut data, &mut encoder).expect("gzip encode"); + encoder.finish().expect("gzip finish") + } + "deflate" => { + let mut encoder = + flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + copy(&mut data, &mut encoder).expect("deflate encode"); + encoder.finish().expect("deflate finish") + } + "zstd" => zstd::stream::encode_all(data, 3).expect("zstd encode"), + _ => panic!("unsupported fixture encoding"), + } +} + +fn headers(encoding: &str) -> HeaderMap { + HeaderMap::from_iter([( + CONTENT_ENCODING, + HeaderValue::from_str(encoding).expect("encoding header"), + )]) +} + +#[test] +fn http_decode_should_preserve_compressed_request_fields() { + let expected = serde_json::from_slice::(REQUEST).expect("fixture JSON"); + for encoding in ENCODINGS { + let compressed = encode_body(encoding, REQUEST); + let decoded = decode_request_with_headers(&compressed, &headers(encoding)).expect(encoding); + assert_eq!( + openai_wire_body(&decoded), + expected.as_object().expect("object") + ); + } +} + +#[test] +fn http_decode_should_accept_plain_identity_and_case_insensitive_encodings() { + for request_headers in [HeaderMap::new(), headers(""), headers(" identity ")] { + assert!(decode_request_with_headers(REQUEST, &request_headers).is_ok()); + } + let compressed = encode_body("gzip", REQUEST); + assert!(decode_request_with_headers(&compressed, &headers(" GZip ")).is_ok()); +} + +#[test] +fn http_decode_should_reject_unknown_and_stacked_encodings() { + let mut repeated = headers("identity"); + repeated.append(CONTENT_ENCODING, HeaderValue::from_static("gzip")); + for request_headers in [headers("br"), headers("gzip, zstd"), repeated] { + let error = decode_request_with_headers(REQUEST, &request_headers) + .expect_err("unsupported encoding"); + assert!(matches!( + error, + RequestDecodeError::UnsupportedContentEncoding { .. } + )); + assert_eq!( + error.protocol_body().into_value()["error"]["code"], + "unsupported_content_encoding" + ); + } +} + +#[test] +fn http_decode_should_reject_corrupted_and_truncated_compressed_bodies() { + for encoding in ENCODINGS { + let mut truncated = encode_body(encoding, REQUEST); + truncated.truncate(truncated.len() - 4); + for body in [b"not-a-compressed-stream".as_slice(), truncated.as_slice()] { + let error = decode_request_with_headers(body, &headers(encoding)).expect_err(encoding); + assert_eq!( + error.protocol_body().into_value(), + json!({"error": { + "type": "invalid_request_error", "code": "invalid_json", + "message": "Request body must be valid JSON." + }}) + ); + } + } +} + +#[test] +fn http_decode_should_read_all_gzip_members_and_zstd_frames() { + for encoding in ["gzip", "zstd"] { + let mut compressed = encode_body(encoding, &REQUEST[..12]); + compressed.extend(encode_body(encoding, &REQUEST[12..])); + let decoded = decode_request_with_headers(&compressed, &headers(encoding)).expect(encoding); + assert_eq!(openai_wire_body(&decoded)["input"], "hello"); + } +} + +#[test] +fn http_decode_should_not_ignore_invalid_trailing_members() { + for encoding in ["gzip", "zstd"] { + let mut compressed = encode_body(encoding, REQUEST); + compressed.extend(encode_body(encoding, b"invalid trailing JSON".as_slice())); + assert_eq!( + decode_request_with_headers(&compressed, &headers(encoding)).expect_err(encoding), + RequestDecodeError::MalformedJson + ); + } +} + +#[test] +fn http_decode_should_reject_zstd_frames_with_an_excessive_window() { + let mut encoder = zstd::stream::write::Encoder::new(Vec::new(), 3).expect("zstd encoder"); + encoder.window_log(27).expect("128 MiB window"); + encoder.write_all(REQUEST).expect("write JSON"); + // 提前输出帧头,避免编码器根据最终小正文缩减窗口。 + encoder.flush().expect("flush frame header"); + let compressed = encoder.finish().expect("finish frame"); + assert_eq!( + zstd::stream::decode_all(compressed.as_slice()).expect("valid zstd frame"), + REQUEST + ); + assert_eq!( + decode_request_with_headers(&compressed, &headers("zstd")).expect_err("oversized window"), + RequestDecodeError::MalformedJson + ); +} + +#[test] +fn http_decode_should_accept_the_exact_decompressed_limit() { + let padding = repeat(b' ').take(DECOMPRESSED_LIMIT - REQUEST.len() as u64); + let compressed = encode_body("zstd", REQUEST.chain(padding)); + let decoded = decode_request_with_headers(&compressed, &headers("zstd")).expect("exact limit"); + assert_eq!(decoded.metadata().requested_model(), "gpt-test"); +} + +#[test] +fn http_decode_should_reject_bodies_above_the_decompressed_limit() { + for encoding in ENCODINGS { + let compressed = encode_body(encoding, repeat(0).take(DECOMPRESSED_LIMIT + 1)); + let error = + decode_request_with_headers(&compressed, &headers(encoding)).expect_err(encoding); + assert_eq!( + error, + RequestDecodeError::DecompressedBodyTooLarge, + "{encoding}" + ); + } +} + +#[test] +fn http_decode_should_stop_at_limit_before_reading_invalid_trailing_frames() { + for encoding in ["zstd", "gzip"] { + // 尾部故意损坏:若先完整解压再检查长度,就会错误地返回 invalid_json, + // 这个断言验证越界时已经停止读取,而不只是最终返回了某个超限错误。 + let mut compressed = encode_body(encoding, repeat(0).take(DECOMPRESSED_LIMIT + 1)); + compressed.extend_from_slice(b"invalid trailing frame"); + let error = + decode_request_with_headers(&compressed, &headers(encoding)).expect_err(encoding); + assert_eq!( + error, + RequestDecodeError::DecompressedBodyTooLarge, + "{encoding}" + ); + } +} diff --git a/docs/api.md b/docs/api.md index 74ceee5d9..980426ab5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -113,8 +113,8 @@ body 不进入这个通用信封。稳定业务码如下: ## 3. OpenAI 数据面与模型目录 -Responses、Images 和 standalone Search HTTP body、WebSocket message 和 frame 不设置网关私有长度上限; -协议可接受性由上游决定。 +除下述 Responses 入站解压保护外,Responses、Images 和 standalone Search HTTP body、 +WebSocket message 和 frame 不设置网关私有长度上限;协议可接受性由上游决定。 | 方法 | 路由 | 说明 | | --- | --- | --- | @@ -129,6 +129,15 @@ Responses、Images 和 standalone Search HTTP body、WebSocket message 和 frame Codex 的 review 等子代理请求仍使用 `/v1/responses`,并通过 `x-openai-subagent` 请求头携带子代理类型; 网关不提供独立的子代理请求路径。 +`POST /v1/responses` 在鉴权后按 `Content-Encoding` 解压,再解析 JSON;支持单一 `gzip`、 +`deflate`(zlib 封装)和 `zstd`,缺省、空值或 `identity` 直接使用原始正文。gzip 多成员与 zstd +多帧连续解码,整体展开结果最多 64 MiB,超限在继续展开前返回 `400 request_too_large`;zstd +回溯窗口同样最多 64 MiB,不能满足该限制的帧按解码失败处理。这个限制保护入站解压资源,不是 +模型上下文或 Token 上限,也不新增未压缩正文的长度限制。 +不支持的编码、逗号分隔的叠加编码和重复 `Content-Encoding` 头返回 +`400 unsupported_content_encoding`;压缩正文损坏、截断或解压后不是合法 JSON 返回 +`400 invalid_json`。本地错误不包含原始正文或解压库细节。WebSocket 文本帧不经过这条解压路径。 + Responses 不透传下游的逐跳头、反代元数据(如 `cf-*`、`x-forwarded-*`、`forwarded`、`via`、 `cdn-loop`)以及 `Accept-Encoding` / `Content-Encoding`。链路元数据和编解码能力 由各段传输层独立管理;其余业务扩展头继续透传,不使用固定业务头白名单。