From 0100aeae87c43867365132b068a70612fc729654 Mon Sep 17 00:00:00 2001 From: reonokiy Date: Tue, 20 Oct 2026 04:31:41 +0000 Subject: [PATCH 1/4] feat: make gateway request size limit configurable --- README.md | 4 +++ docs/common.md | 11 ++++-- src/main.rs | 28 +++++++++++++++ src/realtime.rs | 4 +-- src/server.rs | 10 +++--- src/websocket.rs | 4 +-- tests/catalog.rs | 1 + tests/gateway.rs | 67 +++++++++++++++++++++++++++++++++++ tests/support/live_gateway.rs | 1 + 9 files changed, 120 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 2fa0356..fb6003e 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,10 @@ Or run from source with the same login and environment variable: cargo run --locked --release -- --listen 127.0.0.1:8080 ``` +To allow requests up to 64 MiB, set `CODEX_GATEWAY_MAX_REQUEST_BYTES=67108864` +(pass it with Docker `-e`) or add `--max-request-bytes 67108864` to the executable +arguments. The default is 16 MiB. See [request limits](docs/common.md). + ## Use Generate text with the official OpenAI Python SDK: diff --git a/docs/common.md b/docs/common.md index 2583f52..f0b92d5 100644 --- a/docs/common.md +++ b/docs/common.md @@ -9,13 +9,20 @@ Use `Content-Type: application/json` unless an endpoint documents multipart or W | Limit | Default | | --- | --- | -| Request body / WebSocket message | 16 MiB | -| Decoded native zstd request | 16 MiB | +| Request body / WebSocket message and frame | 16 MiB; `--max-request-bytes` | +| Decoded native zstd request | Same as `--max-request-bytes` | | Buffered tool response / HTTP Responses event stream | 64 MiB | | Upstream model catalog | 16 MiB | | Concurrent generation/native requests or WebSocket connections | 4; `--max-concurrency` | | Request deadline / streaming idle timeout | 300 seconds; `--timeout-seconds` | +Set `CODEX_GATEWAY_MAX_REQUEST_BYTES` or `--max-request-bytes` to a positive +byte count (the CLI flag takes precedence). For example, `67108864` allows +64 MiB HTTP bodies, decoded zstd bodies, and WebSocket messages/frames. +Multipart overhead counts toward the HTTP limit. Restart to apply changes. +The upstream model catalog and buffered response limits are independent; +upstream service limits still apply. + Models and health checks do not occupy generation slots. Images, standalone search, uploads and native routes share slots with Responses. Signed transfers have separate limits. WebSocket slots last for the connection lifetime. ## Startup login diff --git a/src/main.rs b/src/main.rs index 2466ce9..d44905a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,7 +20,18 @@ struct Args { max_concurrency: u16, #[arg(long, default_value_t=300, value_parser=clap::value_parser!(u64).range(1..))] timeout_seconds: u64, + /// Maximum HTTP body, decoded zstd body, and WebSocket message/frame size in bytes. + #[arg(long, env = "CODEX_GATEWAY_MAX_REQUEST_BYTES", default_value_t = 16 * 1024 * 1024, value_parser = parse_request_bytes)] + max_request_bytes: usize, } +fn parse_request_bytes(value: &str) -> Result { + let bytes = value.parse::().map_err(|e| e.to_string())?; + if bytes == 0 || bytes > isize::MAX as usize { + return Err("must be between 1 and isize::MAX bytes".into()); + } + Ok(bytes) +} + #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() @@ -68,6 +79,7 @@ async fn main() -> anyhow::Result<()> { args.max_concurrency, ))), timeout, + max_request_bytes: args.max_request_bytes, transfers, }; let listener = tokio::net::TcpListener::bind(args.listen).await?; @@ -90,3 +102,19 @@ async fn main() -> anyhow::Result<()> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_limit_cli_validation() { + for value in ["1", "16777216", "67108864"] { + let args = Args::try_parse_from(["gateway", "--max-request-bytes", value]).unwrap(); + assert_eq!(args.max_request_bytes, value.parse::().unwrap()); + } + for value in ["0", "-1", "invalid", "18446744073709551615"] { + assert!(Args::try_parse_from(["gateway", "--max-request-bytes", value]).is_err()); + } + } +} diff --git a/src/realtime.rs b/src/realtime.rs index 8bdca9a..fafa61c 100644 --- a/src/realtime.rs +++ b/src/realtime.rs @@ -172,8 +172,8 @@ pub async fn upgrade( .map(str::to_owned); let mut response = ws .protocols(protocols) - .max_message_size(16 * 1024 * 1024) - .max_frame_size(16 * 1024 * 1024) + .max_message_size(gateway.max_request_bytes) + .max_frame_size(gateway.max_request_bytes) .on_upgrade(move |client| async move { let _permit = permit; crate::websocket::relay(client, upstream, idle, false, &[], "").await; diff --git a/src/server.rs b/src/server.rs index 5d6e8c2..1be6922 100644 --- a/src/server.rs +++ b/src/server.rs @@ -19,6 +19,7 @@ pub struct Gateway { pub key: Option, pub concurrency: Arc, pub timeout: Duration, + pub max_request_bytes: usize, pub transfers: Option, pub telemetry: Option>, } @@ -71,7 +72,7 @@ pub fn router(gateway: Gateway) -> Router { .route("/v1/live/sessions/{session_id}/attach", get(crate::realtime::upgrade)) .route("/transfers/{handle}", axum::routing::any(transfer)) .fallback(crate::native::handle) - .layer(DefaultBodyLimit::max(16 * 1024 * 1024)) + .layer(DefaultBodyLimit::max(gateway.max_request_bytes)) .with_state(Arc::new(gateway)) } async fn transfer( @@ -274,6 +275,7 @@ async fn native_create( body: Bytes, ) -> Result { authorize(&gateway, &headers)?; + let max_request_bytes = gateway.max_request_bytes; let body = match headers .get("content-encoding") .and_then(|v| v.to_str().ok()) @@ -285,15 +287,15 @@ async fn native_create( .map_err(|_| GatewayError::invalid("invalid zstd request"))?; let mut decoded = Vec::new(); decoder - .take(16 * 1024 * 1024 + 1) + .take(max_request_bytes as u64 + 1) .read_to_end(&mut decoded) .map_err(|_| GatewayError::invalid("invalid zstd request"))?; - if decoded.len() > 16 * 1024 * 1024 { + if decoded.len() > max_request_bytes { return Err(GatewayError { upstream_response: None, status: StatusCode::PAYLOAD_TOO_LARGE, code: "invalid_request_error", - message: "decoded body exceeds 16 MiB".into(), + message: format!("decoded body exceeds {max_request_bytes} bytes"), }); } Ok(decoded) diff --git a/src/websocket.rs b/src/websocket.rs index 1372e8f..7985185 100644 --- a/src/websocket.rs +++ b/src/websocket.rs @@ -53,8 +53,8 @@ pub async fn upgrade( .map(str::to_owned) .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); let mut response = ws - .max_message_size(16 * 1024 * 1024) - .max_frame_size(16 * 1024 * 1024) + .max_message_size(gateway.max_request_bytes) + .max_frame_size(gateway.max_request_bytes) .on_upgrade(move |client| async move { let _permit = permit; relay_with_telemetry( diff --git a/tests/catalog.rs b/tests/catalog.rs index 55ca0fb..e50623a 100644 --- a/tests/catalog.rs +++ b/tests/catalog.rs @@ -73,6 +73,7 @@ async fn startup_catalog_exposes_new_models_and_uses_their_capabilities() { ); } let app = router(Gateway { + max_request_bytes: 16 * 1024 * 1024, telemetry: None, backend, models, diff --git a/tests/gateway.rs b/tests/gateway.rs index e77aa18..62d9bd3 100644 --- a/tests/gateway.rs +++ b/tests/gateway.rs @@ -202,6 +202,26 @@ impl Harness { timeout: Duration, login: CodexAuth, transfers: bool, + ) -> Self { + Self::with_request_limit( + events, + status, + hang, + timeout, + login, + transfers, + 16 * 1024 * 1024, + ) + .await + } + async fn with_request_limit( + events: Vec, + status: StatusCode, + hang: bool, + timeout: Duration, + login: CodexAuth, + transfers: bool, + max_request_bytes: usize, ) -> Self { let subscription = login.is_chatgpt_auth(); let fake = Fake { @@ -305,6 +325,7 @@ impl Harness { .unwrap() }); let app = router(Gateway { + max_request_bytes, telemetry: None, backend, models, @@ -1689,3 +1710,49 @@ supports_websockets = {} > 0 ); } + +#[tokio::test] +async fn configured_request_limit_applies_to_http_and_decoded_zstd() { + let h = Harness::with_request_limit( + events(), + StatusCode::OK, + false, + Duration::from_secs(5), + CodexAuth::from_api_key("upstream-secret"), + false, + 1024, + ) + .await; + let client = reqwest::Client::new(); + for path in ["/v1/responses", "/codex/responses"] { + let response = client + .post(format!("{}{path}", h.url)) + .bearer_auth("client-key") + .body(vec![b' '; 1025]) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + } + for length in [1024, 1025] { + let compressed = zstd::encode_all(vec![b' '; length].as_slice(), 1).unwrap(); + let response = client + .post(format!("{}/codex/responses", h.url)) + .bearer_auth("client-key") + .header("content-encoding", "zstd") + .body(compressed) + .send() + .await + .unwrap(); + // At the limit the body reaches JSON validation; one byte over is rejected first. + assert_eq!( + response.status(), + if length == 1024 { + StatusCode::BAD_REQUEST + } else { + StatusCode::PAYLOAD_TOO_LARGE + } + ); + } + assert!(h.fake.received.lock().unwrap().is_empty()); +} diff --git a/tests/support/live_gateway.rs b/tests/support/live_gateway.rs index 4afea95..3be2bd3 100644 --- a/tests/support/live_gateway.rs +++ b/tests/support/live_gateway.rs @@ -53,6 +53,7 @@ impl LiveGateway { let model_catalog = Some(models.clone()); let observed = transports.clone(); let app = router(Gateway { + max_request_bytes: 16 * 1024 * 1024, telemetry: None, backend, models, From 038454af19d7956ce806aa698695fcf90e98473b Mon Sep 17 00:00:00 2001 From: reonokiy Date: Tue, 20 Oct 2026 04:34:30 +0000 Subject: [PATCH 2/4] feat: default request size limit to 64 MiB --- README.md | 4 ++-- docs/common.md | 2 +- src/main.rs | 16 +++++++++++++++- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index fb6003e..f3fd6e3 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,9 @@ Or run from source with the same login and environment variable: cargo run --locked --release -- --listen 127.0.0.1:8080 ``` -To allow requests up to 64 MiB, set `CODEX_GATEWAY_MAX_REQUEST_BYTES=67108864` +The request size limit defaults to 64 MiB. Override it with `CODEX_GATEWAY_MAX_REQUEST_BYTES=67108864` (pass it with Docker `-e`) or add `--max-request-bytes 67108864` to the executable -arguments. The default is 16 MiB. See [request limits](docs/common.md). +arguments. See [request limits](docs/common.md). ## Use diff --git a/docs/common.md b/docs/common.md index f0b92d5..4e038ed 100644 --- a/docs/common.md +++ b/docs/common.md @@ -9,7 +9,7 @@ Use `Content-Type: application/json` unless an endpoint documents multipart or W | Limit | Default | | --- | --- | -| Request body / WebSocket message and frame | 16 MiB; `--max-request-bytes` | +| Request body / WebSocket message and frame | 64 MiB; `--max-request-bytes` | | Decoded native zstd request | Same as `--max-request-bytes` | | Buffered tool response / HTTP Responses event stream | 64 MiB | | Upstream model catalog | 16 MiB | diff --git a/src/main.rs b/src/main.rs index d44905a..8565e15 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,7 +21,7 @@ struct Args { #[arg(long, default_value_t=300, value_parser=clap::value_parser!(u64).range(1..))] timeout_seconds: u64, /// Maximum HTTP body, decoded zstd body, and WebSocket message/frame size in bytes. - #[arg(long, env = "CODEX_GATEWAY_MAX_REQUEST_BYTES", default_value_t = 16 * 1024 * 1024, value_parser = parse_request_bytes)] + #[arg(long, env = "CODEX_GATEWAY_MAX_REQUEST_BYTES", default_value_t = 64 * 1024 * 1024, value_parser = parse_request_bytes)] max_request_bytes: usize, } fn parse_request_bytes(value: &str) -> Result { @@ -107,6 +107,20 @@ async fn main() -> anyhow::Result<()> { mod tests { use super::*; + #[test] + fn request_limit_defaults_to_64_mib() { + use clap::CommandFactory; + let command = Args::command(); + let argument = command + .get_arguments() + .find(|arg| arg.get_id() == "max_request_bytes") + .unwrap(); + assert_eq!( + argument.get_default_values(), + &[std::ffi::OsString::from("67108864")] + ); + } + #[test] fn request_limit_cli_validation() { for value in ["1", "16777216", "67108864"] { From ea141130342680bbfc9a34fbef6974b8a8c48d79 Mon Sep 17 00:00:00 2001 From: reonokiy Date: Tue, 20 Oct 2026 04:57:43 +0000 Subject: [PATCH 3/4] fix: enforce configured limits on native WebSocket upgrades --- src/native.rs | 4 +- tests/cases/native.rs | 86 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/src/native.rs b/src/native.rs index 5d7f310..60a7f16 100644 --- a/src/native.rs +++ b/src/native.rs @@ -198,7 +198,9 @@ pub async fn handle( use axum::extract::FromRequestParts; let ws = WebSocketUpgrade::from_request_parts(&mut parts, &()) .await - .map_err(|e| GatewayError::invalid(e.to_string()))?; + .map_err(|e| GatewayError::invalid(e.to_string()))? + .max_message_size(gateway.max_request_bytes) + .max_frame_size(gateway.max_request_bytes); for name in [ "sec-websocket-key", "sec-websocket-version", diff --git a/tests/cases/native.rs b/tests/cases/native.rs index 630e784..6697be2 100644 --- a/tests/cases/native.rs +++ b/tests/cases/native.rs @@ -563,3 +563,89 @@ async fn audio_prefix_preserves_oauth_requests_and_upstream_errors() { assert_eq!(response.status(), StatusCode::NOT_FOUND); assert_eq!(h.fake.received.lock().unwrap().len(), 2); } + +#[tokio::test] +async fn native_websockets_enforce_configured_message_and_frame_limits() { + use futures::{SinkExt, StreamExt}; + use tokio_tungstenite::tungstenite::protocol::frame::{ + Frame, + coding::{Data, OpCode}, + }; + use tokio_tungstenite::tungstenite::{Message, client::IntoClientRequest}; + for (path, delegated) in [ + ("/codex/guardian", false), + ("/codex/guardian-classifier", false), + ("/backend-api/wham/remote/control/server", true), + ] { + for fragmented in [false, true] { + let h = Harness::with_request_limit( + vec![], + StatusCode::OK, + false, + Duration::from_secs(5), + CodexAuth::from_api_key("upstream-secret"), + false, + 1024, + ) + .await; + let mut request = format!("{}{path}", h.url.replace("http:", "ws:")) + .into_client_request() + .unwrap(); + request.headers_mut().insert( + "authorization", + if delegated { + "Bearer remote-token" + } else { + "Bearer client-key" + } + .parse() + .unwrap(), + ); + if delegated { + request.headers_mut().insert( + "x-codex-gateway-authorization", + "Bearer client-key".parse().unwrap(), + ); + } + let (mut ws, _) = tokio_tungstenite::connect_async(request).await.unwrap(); + // An exact-limit binary message must make the complete round trip. + ws.send(Message::Binary(vec![7; 1024].into())) + .await + .unwrap(); + let reply = tokio::time::timeout(Duration::from_secs(2), ws.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(reply, Message::Binary(vec![7; 1024].into()), "{path}"); + if fragmented { + // Each frame fits, but the assembled message exceeds the limit. + ws.send(Message::Frame(Frame::message( + vec![7; 512], + OpCode::Data(Data::Binary), + false, + ))) + .await + .unwrap(); + ws.send(Message::Frame(Frame::message( + vec![7; 513], + OpCode::Data(Data::Continue), + true, + ))) + .await + .unwrap(); + } else { + ws.send(Message::Binary(vec![7; 1025].into())) + .await + .unwrap(); + } + let reply = tokio::time::timeout(Duration::from_secs(2), ws.next()) + .await + .unwrap(); + assert!( + matches!(reply, None | Some(Err(_)) | Some(Ok(Message::Close(_)))), + "{path} fragmented={fragmented}: {reply:?}" + ); + } + } +} From ce9dfc87a7b2cf9d005f6b510d2c1cb762b5c421 Mon Sep 17 00:00:00 2001 From: reonokiy Date: Tue, 20 Oct 2026 04:57:43 +0000 Subject: [PATCH 4/4] test: exercise real HTTP request size boundaries --- tests/gateway.rs | 74 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/gateway.rs b/tests/gateway.rs index 62d9bd3..7ba381f 100644 --- a/tests/gateway.rs +++ b/tests/gateway.rs @@ -288,6 +288,7 @@ impl Harness { }), ) .fallback(native_cases::upstream_native) + .layer(axum::extract::DefaultBodyLimit::max(max_request_bytes)) .with_state(fake.clone()); let upstream_task = tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); @@ -1756,3 +1757,76 @@ async fn configured_request_limit_applies_to_http_and_decoded_zstd() { } assert!(h.fake.received.lock().unwrap().is_empty()); } + +#[tokio::test] +async fn real_http_64_mib_body_boundaries_and_large_header() { + let limit = 64 * 1024 * 1024; + let h = Harness::with_request_limit( + events(), + StatusCode::OK, + false, + Duration::from_secs(30), + CodexAuth::from_api_key("upstream-secret"), + false, + limit, + ) + .await; + let client = reqwest::Client::new(); + for path in [ + "/v1/responses", + "/codex/responses", + "/backend-api/codex/responses", + ] { + for size in [16 * 1024 * 1024 + 1, limit, limit + 1] { + let mut body = serde_json::to_vec( + &json!({"model":h.model,"input":[{"role":"user","content":"hello"}],"stream":true}), + ) + .unwrap(); + body.resize(size, b' '); + let response = client + .post(format!("{}{path}", h.url)) + .bearer_auth("client-key") + .header("content-type", "application/json") + .body(body) + .send() + .await + .unwrap(); + let status = response.status(); + let text = response.text().await.unwrap(); + println!("POST {path}: body={size} status={status}"); + assert_eq!( + status, + if size > limit { + StatusCode::PAYLOAD_TOO_LARGE + } else { + StatusCode::OK + }, + "{text}" + ); + if size <= limit { + assert!(text.contains("你好"), "{text}"); + } + } + } + for size in [8 * 1024, 64 * 1024, 1024 * 1024] { + let response = h + .request(json!({"input":"hello","stream":false})) + .header("x-test-padding", "a".repeat(size)) + .send() + .await + .unwrap(); + println!( + "POST /v1/responses: header-value={size} status={}", + response.status() + ); + assert_eq!( + response.status(), + if size <= 64 * 1024 { + StatusCode::OK + } else { + StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE + } + ); + } + assert_eq!(h.fake.received.lock().unwrap().len(), 8); +}