diff --git a/config.example.yaml b/config.example.yaml index cd0952f0..800111c2 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -33,7 +33,13 @@ etcd: proxy: addr: "0.0.0.0:3000" - request_body_limit_bytes: 10485760 # 10 MiB + # Cap on inbound request bodies (JSON, multipart, passthrough, MCP, + # A2A). 0 — the default — disables the cap: providers accept larger + # requests than any fixed gateway default (Anthropic takes 32 MB), so + # a gateway-side cap rejects requests the upstream would have served. + # Set a value to bound per-request memory; over-limit requests get a + # 413 in the caller's error envelope. + # request_body_limit_bytes: 0 # tls: # cert_file: "/etc/aisix/tls/proxy.crt" # key_file: "/etc/aisix/tls/proxy.key" diff --git a/config.managed.yaml b/config.managed.yaml index a9a774a5..8acadd82 100644 --- a/config.managed.yaml +++ b/config.managed.yaml @@ -41,7 +41,9 @@ etcd: proxy: addr: "0.0.0.0:3000" - request_body_limit_bytes: 10485760 + # 0 = no request-body cap (the default); set a value to bound + # per-request memory. + # request_body_limit_bytes: 0 admin: # Bind to an unbindable port so even if managed mode somehow diff --git a/crates/aisix-core/src/config.rs b/crates/aisix-core/src/config.rs index ce20331e..42293ee6 100644 --- a/crates/aisix-core/src/config.rs +++ b/crates/aisix-core/src/config.rs @@ -375,6 +375,14 @@ impl EtcdConfig { #[serde(deny_unknown_fields)] pub struct ProxyConfig { pub addr: String, + /// Cap on inbound request bodies across the whole proxy surface + /// (JSON, multipart, passthrough, MCP, A2A). `0` — the default — + /// disables the cap, matching the reference LLM proxy's + /// out-of-box behaviour: providers accept larger requests than any + /// fixed gateway default (Anthropic takes 32 MB), so a gateway-side + /// cap rejects requests the upstream would have served. Set a value + /// to bound per-request memory; over-limit requests get a 413 in + /// the caller's error envelope. #[serde(default = "ProxyConfig::default_body_limit")] pub request_body_limit_bytes: usize, #[serde(default)] @@ -389,7 +397,7 @@ pub struct ProxyConfig { impl ProxyConfig { const fn default_body_limit() -> usize { - 10 * 1024 * 1024 + 0 } } @@ -1082,7 +1090,9 @@ admin: ); let cfg = Config::load_from_path(Some(f.path())).unwrap(); assert_eq!(cfg.etcd.endpoints, vec!["http://127.0.0.1:2379"]); - assert_eq!(cfg.proxy.request_body_limit_bytes, 10 * 1024 * 1024); + // `0` = no request-body cap, the out-of-box behaviour of the + // reference LLM proxy. + assert_eq!(cfg.proxy.request_body_limit_bytes, 0); assert!(cfg.observability.metrics.prometheus.enabled); // The dedicated metrics listener defaults to 0.0.0.0:9090 in // every mode — no admin-listener fallback to fall out of sync with. diff --git a/crates/aisix-proxy/src/a2a.rs b/crates/aisix-proxy/src/a2a.rs index e27b1f8e..e722b36a 100644 --- a/crates/aisix-proxy/src/a2a.rs +++ b/crates/aisix-proxy/src/a2a.rs @@ -128,8 +128,21 @@ async fn dispatch( let upstream = upstream_from_a2a_agent(&entry.value); let (_parts, body) = request.into_parts(); - let bytes = match to_bytes(body, state.request_body_limit_bytes).await { + let bytes = match to_bytes( + body, + crate::error::body_read_cap(state.request_body_limit_bytes), + ) + .await + { Ok(bytes) => bytes, + // Cap hit → 413 in the standard envelope, matching the + // Content-Length middleware's answer on this route. + Err(err) if crate::error::is_length_limit_error(&err) => { + return crate::error::ProxyError::RequestTooLarge { + limit_bytes: state.request_body_limit_bytes, + } + .into_response(); + } Err(_) => return (StatusCode::BAD_REQUEST, "invalid request body").into_response(), }; let value: serde_json::Value = match serde_json::from_slice(&bytes) { @@ -411,6 +424,35 @@ mod tests { b.body(Body::from(body.to_string())).unwrap() } + #[tokio::test] + async fn chunked_oversized_body_returns_enveloped_413() { + // Same contract as /mcp: a chunked body over the cap surfaces as + // the enveloped 413 from the handler's capped read, not the old + // bare 400. + let app = router_with(snapshot_with( + "http://127.0.0.1:1/a2a", + true, + serde_json::json!(["invoice"]), + )); + let chunk = vec![b'a'; 200 * 1024]; + let stream = + futures::stream::iter((0..10).map(move |_| Ok::<_, std::io::Error>(chunk.clone()))); + let req = HttpRequest::post("/a2a/invoice") + .header("host", "a2a.aisix.example.com") + .header("content-type", "application/json") + .header("authorization", format!("Bearer {TOKEN}")) + .body(Body::from_stream(stream)) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE); + let body = axum::body::to_bytes(resp.into_body(), 64 * 1024) + .await + .expect("read body"); + let v: serde_json::Value = + serde_json::from_slice(&body).expect("413 must carry the JSON envelope"); + assert_eq!(v["error"]["type"], "invalid_request_error"); + } + #[tokio::test] async fn endpoint_denies_key_without_allowed_agents_403() { // Unreachable upstream on purpose: the ACL must reject BEFORE any diff --git a/crates/aisix-proxy/src/audio.rs b/crates/aisix-proxy/src/audio.rs index ec865502..1e833047 100644 --- a/crates/aisix-proxy/src/audio.rs +++ b/crates/aisix-proxy/src/audio.rs @@ -282,8 +282,20 @@ pub async fn speech( State(state): State, auth: AuthenticatedKey, client: ClientContext, - Json(body): Json, + // Result-wrapped so an extractor-layer 413 maps to the OpenAI + // envelope — see completions.rs. + body: Result, axum::extract::rejection::JsonRejection>, ) -> Response { + let Json(body) = match body { + Ok(json) => json, + Err(rej) => { + return crate::error::proxy_error_from_json_rejection( + rej, + state.request_body_limit_bytes, + ) + .into_response(); + } + }; let started = Instant::now(); let request_id = client.request_id.clone(); let api_key_id = auth.entry.id.clone(); @@ -407,18 +419,23 @@ async fn multipart_dispatch( // outgoing reqwest multipart. let mut fields: Vec<(String, Option, Option, Bytes)> = Vec::new(); - while let Some(field) = multipart - .next_field() - .await - .map_err(|e| ProxyError::InvalidRequest(format!("multipart read error: {e}")))? - { + while let Some(field) = multipart.next_field().await.map_err(|e| { + crate::error::proxy_error_from_multipart( + e, + state.request_body_limit_bytes, + "multipart read error", + ) + })? { let name = field.name().unwrap_or("").to_string(); let file_name = field.file_name().map(|s| s.to_string()); let content_type = field.content_type().map(|s| s.to_string()); - let data = field - .bytes() - .await - .map_err(|e| ProxyError::InvalidRequest(format!("multipart field read error: {e}")))?; + let data = field.bytes().await.map_err(|e| { + crate::error::proxy_error_from_multipart( + e, + state.request_body_limit_bytes, + "multipart field read error", + ) + })?; fields.push((name, file_name, content_type, data)); } diff --git a/crates/aisix-proxy/src/completions.rs b/crates/aisix-proxy/src/completions.rs index 31e3f930..e81b5d1e 100644 --- a/crates/aisix-proxy/src/completions.rs +++ b/crates/aisix-proxy/src/completions.rs @@ -86,8 +86,22 @@ pub async fn completions( State(state): State, auth: AuthenticatedKey, client: ClientContext, - Json(body): Json, + // Result-wrapped so an extractor-layer 413 (chunked body over the + // cap) maps to the OpenAI envelope instead of axum's stock + // text/plain rejection — same discriminate-then-map pattern as + // chat.rs / messages.rs. + body: Result, axum::extract::rejection::JsonRejection>, ) -> Response { + let Json(body) = match body { + Ok(json) => json, + Err(rej) => { + return crate::error::proxy_error_from_json_rejection( + rej, + state.request_body_limit_bytes, + ) + .into_response(); + } + }; let started = Instant::now(); let request_id = client.request_id.clone(); let api_key_id = auth.entry.id.clone(); diff --git a/crates/aisix-proxy/src/error.rs b/crates/aisix-proxy/src/error.rs index df276283..5cc43b2d 100644 --- a/crates/aisix-proxy/src/error.rs +++ b/crates/aisix-proxy/src/error.rs @@ -617,6 +617,65 @@ pub(crate) fn proxy_error_from_json_rejection( } } +/// [`proxy_error_from_json_rejection`]'s sibling for handlers that take +/// the raw `Bytes` extractor (batches / fine-tuning): same 413-vs-400 +/// discrimination, no JSON layer. +pub(crate) fn proxy_error_from_bytes_rejection( + rej: axum::extract::rejection::BytesRejection, + limit_bytes: usize, +) -> ProxyError { + if rej.status() == StatusCode::PAYLOAD_TOO_LARGE { + ProxyError::RequestTooLarge { limit_bytes } + } else { + ProxyError::InvalidRequest("failed to read request body".into()) + } +} + +/// Map a multipart read failure, preserving axum's 413 discrimination: +/// an over-cap stream or part is a real `RequestTooLarge` (axum's +/// `MultipartError::status()` already classifies it 413); everything +/// else stays the 400 the call site describes via `context`. Without +/// this, an over-limit chunked upload surfaced as a generic 400 +/// `invalid_request_error` instead of `request_too_large`. +pub(crate) fn proxy_error_from_multipart( + err: axum::extract::multipart::MultipartError, + limit_bytes: usize, + context: &str, +) -> ProxyError { + if err.status() == StatusCode::PAYLOAD_TOO_LARGE { + ProxyError::RequestTooLarge { limit_bytes } + } else { + ProxyError::InvalidRequest(format!("{context}: {err}")) + } +} + +/// Cap for manual `axum::body::to_bytes` reads: the configured +/// `request_body_limit_bytes` with the `0` = "no cap" sentinel widened to +/// `usize::MAX`, mirroring what `DefaultBodyLimit::disable()` does on the +/// extractor path. +pub(crate) fn body_read_cap(limit_bytes: usize) -> usize { + if limit_bytes == 0 { + usize::MAX + } else { + limit_bytes + } +} + +/// Whether a manual body read failed because it hit the length cap +/// (→ 413) rather than a transport fault (→ 400). `axum::body::to_bytes` +/// folds both into one opaque `axum::Error`; the cap case carries +/// `http_body_util::LengthLimitError` in its source chain. +pub(crate) fn is_length_limit_error(err: &axum::Error) -> bool { + let mut source: Option<&(dyn std::error::Error + 'static)> = Some(err); + while let Some(e) = source { + if e.is::() { + return true; + } + source = e.source(); + } + false +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/aisix-proxy/src/images.rs b/crates/aisix-proxy/src/images.rs index 72588d37..3708247a 100644 --- a/crates/aisix-proxy/src/images.rs +++ b/crates/aisix-proxy/src/images.rs @@ -66,8 +66,20 @@ pub async fn image_generations( State(state): State, auth: AuthenticatedKey, client: ClientContext, - Json(body): Json, + // Result-wrapped so an extractor-layer 413 maps to the OpenAI + // envelope — see completions.rs. + body: Result, axum::extract::rejection::JsonRejection>, ) -> Response { + let Json(body) = match body { + Ok(json) => json, + Err(rej) => { + return crate::error::proxy_error_from_json_rejection( + rej, + state.request_body_limit_bytes, + ) + .into_response(); + } + }; let started = Instant::now(); let request_id = client.request_id.clone(); let api_key_id = auth.entry.id.clone(); diff --git a/crates/aisix-proxy/src/jobs.rs b/crates/aisix-proxy/src/jobs.rs index b15c5d7f..d003eb0d 100644 --- a/crates/aisix-proxy/src/jobs.rs +++ b/crates/aisix-proxy/src/jobs.rs @@ -790,15 +790,21 @@ pub(crate) async fn create_file( let mut form_model: Option = None; let mut file_bytes: Option = None; - while let Some(field) = multipart - .next_field() - .await - .map_err(|e| ProxyError::InvalidRequest(format!("malformed multipart body: {e}")))? - { + while let Some(field) = multipart.next_field().await.map_err(|e| { + crate::error::proxy_error_from_multipart( + e, + state.request_body_limit_bytes, + "malformed multipart body", + ) + })? { let name = field.name().unwrap_or_default().to_string(); if name == "model" { let v = field.text().await.map_err(|e| { - ProxyError::InvalidRequest(format!("malformed multipart field: {e}")) + crate::error::proxy_error_from_multipart( + e, + state.request_body_limit_bytes, + "malformed multipart field", + ) })?; if !v.trim().is_empty() { form_model = Some(v.trim().to_string()); @@ -809,7 +815,11 @@ pub(crate) async fn create_file( let file_name = field.file_name().unwrap_or("file").to_string(); let content_type = field.content_type().map(str::to_string); let bytes = field.bytes().await.map_err(|e| { - ProxyError::InvalidRequest(format!("failed to read file field: {e}")) + crate::error::proxy_error_from_multipart( + e, + state.request_body_limit_bytes, + "failed to read file field", + ) })?; let mut part = reqwest::multipart::Part::bytes(bytes.to_vec()).file_name(file_name); if let Some(ct) = content_type { @@ -822,7 +832,11 @@ pub(crate) async fn create_file( continue; } let v = field.text().await.map_err(|e| { - ProxyError::InvalidRequest(format!("malformed multipart field: {e}")) + crate::error::proxy_error_from_multipart( + e, + state.request_body_limit_bytes, + "malformed multipart field", + ) })?; form = form.text(name, v); } @@ -1008,8 +1022,21 @@ pub(crate) async fn create_batch( client: ClientContext, Query(params): Query>, headers: HeaderMap, - body: Bytes, + // Result-wrapped so an extractor-layer 413 (chunked body over the + // cap) maps to the OpenAI envelope instead of axum's stock + // text/plain rejection — see completions.rs. + body: Result, ) -> Response { + let body = match body { + Ok(bytes) => bytes, + Err(rej) => { + return crate::error::proxy_error_from_bytes_rejection( + rej, + state.request_body_limit_bytes, + ) + .into_response(); + } + }; let started = Instant::now(); let request_id = client.request_id.clone(); let mut monitor_hits: Vec = Vec::new(); @@ -1235,8 +1262,21 @@ pub(crate) async fn create_ft_job( client: ClientContext, Query(params): Query>, headers: HeaderMap, - body: Bytes, + // Result-wrapped so an extractor-layer 413 (chunked body over the + // cap) maps to the OpenAI envelope instead of axum's stock + // text/plain rejection — see completions.rs. + body: Result, ) -> Response { + let body = match body { + Ok(bytes) => bytes, + Err(rej) => { + return crate::error::proxy_error_from_bytes_rejection( + rej, + state.request_body_limit_bytes, + ) + .into_response(); + } + }; let started = Instant::now(); let request_id = client.request_id.clone(); let mut monitor_hits: Vec = Vec::new(); diff --git a/crates/aisix-proxy/src/lib.rs b/crates/aisix-proxy/src/lib.rs index 742a60cc..750cbdcd 100644 --- a/crates/aisix-proxy/src/lib.rs +++ b/crates/aisix-proxy/src/lib.rs @@ -177,7 +177,14 @@ pub fn build_router(state: ProxyState) -> Router { // catches the Content-Length-known case ahead of the // extractor; this layer catches chunked / size-mismatched // bodies once their actual byte count exceeds the cap. - .layer(axum::extract::DefaultBodyLimit::max(body_limit)) + // `0` = no cap — `disable()` rather than omitting the layer, + // because omitting it would fall back to axum's 2 MiB, not to + // "unlimited". + .layer(if body_limit > 0 { + axum::extract::DefaultBodyLimit::max(body_limit) + } else { + axum::extract::DefaultBodyLimit::disable() + }) .layer(middleware::from_fn_with_state( state.clone(), enforce_request_body_limit, @@ -362,11 +369,14 @@ async fn enforce_request_body_limit( "conflicting Content-Length headers".into(), )); } + // `0` = the cap is disabled; the duplicate-Content-Length rejection + // above still applies — that one is request-smuggling hygiene, not a + // size limit. if let Some(declared) = first .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse::().ok()) { - if declared > state.request_body_limit_bytes { + if state.request_body_limit_bytes > 0 && declared > state.request_body_limit_bytes { // Drain the inbound body so hyper can flush the 413 response // on the same HTTP/1.1 connection. Without this, hyper closes // the socket while the client is still writing, and the client @@ -1437,6 +1447,273 @@ mod tests { ); } + fn build_state_with_limit(snapshot: AisixSnapshot, hub: Arc, limit: usize) -> ProxyState { + let handle = SnapshotHandle::new(snapshot); + let cfg = ProxyConfig { + addr: "127.0.0.1:0".into(), + request_body_limit_bytes: limit, + real_ip: Default::default(), + tls: None, + }; + ProxyState::new(handle, hub, &cfg).without_cache() + } + + /// `request_body_limit_bytes: 0` (the default) disables the cap + /// entirely. The load-bearing detail is axum's BUILT-IN 2 MiB + /// `DefaultBodyLimit`: merely skipping our `max(limit)` layer would + /// still reject bodies over 2 MiB with a stock rejection, so the + /// router must install `DefaultBodyLimit::disable()`. A 2.5 MiB body + /// — over axum's built-in cap — must reach the handler on both the + /// declared-Content-Length path and the chunked path. + #[tokio::test] + async fn zero_limit_admits_bodies_over_axums_builtin_cap() { + let hub = Arc::new(Hub::new()); + let snap = seed_snapshot("my-gpt4", &["my-gpt4"], "http://unused"); + let app = build_router(build_state_with_limit(snap, hub, 0)); + + let filler = "x".repeat(2 * 1024 * 1024 + 512 * 1024); // 2.5 MiB + let body = + format!(r#"{{"model":"my-gpt4","messages":[{{"role":"user","content":"{filler}"}}]}}"#); + + // Declared Content-Length path (the middleware's early check). + let req = Request::builder() + .method("POST") + .uri("/v1/chat/completions") + .header("authorization", "Bearer sk-caller") + .header("content-type", "application/json") + .header("content-length", body.len().to_string()) + .body(Body::from(body.clone())) + .unwrap(); + let resp = run(app.clone(), req).await; + assert_ne!( + resp.status(), + StatusCode::PAYLOAD_TOO_LARGE, + "limit 0 must not reject on declared size" + ); + // The body parsed and dispatch ran (and failed on the unusable + // upstream) — proving the request got PAST the extractor. + assert!( + resp.status().is_server_error(), + "expected an upstream dispatch failure, got {}", + resp.status() + ); + + // Chunked path (no Content-Length): this is the one axum's + // built-in 2 MiB default would kill without `disable()`. + let chunks: Vec<_> = body + .into_bytes() + .chunks(200 * 1024) + .map(|c| c.to_vec()) + .collect(); + let stream = futures::stream::iter(chunks.into_iter().map(Ok::<_, std::io::Error>)); + let req = Request::builder() + .method("POST") + .uri("/v1/chat/completions") + .header("authorization", "Bearer sk-caller") + .header("content-type", "application/json") + .body(Body::from_stream(stream)) + .unwrap(); + let resp = run(app, req).await; + assert_ne!( + resp.status(), + StatusCode::PAYLOAD_TOO_LARGE, + "limit 0 must not cap a chunked body at axum's 2 MiB default" + ); + assert!(resp.status().is_server_error()); + } + + /// The duplicate-Content-Length rejection is smuggling hygiene, not + /// a size limit — it must keep firing when the cap is disabled. + #[tokio::test] + async fn zero_limit_still_rejects_duplicate_content_length() { + let hub = Arc::new(Hub::new()); + let snap = seed_snapshot("my-gpt4", &["my-gpt4"], "http://unused"); + let app = build_router(build_state_with_limit(snap, hub, 0)); + + let body = r#"{"model":"my-gpt4","messages":[{"role":"user","content":"hi"}]}"#; + let mut req = Request::builder() + .method("POST") + .uri("/v1/chat/completions") + .header("authorization", "Bearer sk-caller") + .header("content-type", "application/json") + .body(Body::from(body)) + .unwrap(); + req.headers_mut().append( + axum::http::header::CONTENT_LENGTH, + axum::http::HeaderValue::from(body.len()), + ); + req.headers_mut().append( + axum::http::header::CONTENT_LENGTH, + axum::http::HeaderValue::from(body.len() + 1), + ); + let resp = run(app, req).await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + /// Chunked oversize on a handler that used to take a bare + /// `Json` extractor: the rejection must be the OpenAI + /// envelope, not axum's stock `text/plain` 413. (The + /// Content-Length path was already correct via the middleware; + /// the chunked path leaked the stock rejection.) + #[tokio::test] + async fn chunked_oversize_on_v1_completions_returns_openai_envelope() { + let hub = Arc::new(Hub::new()); + let snap = seed_snapshot("my-gpt4", &["my-gpt4"], "http://unused"); + let app = build_router(build_state(snap, hub)); + + let chunk = vec![b'x'; 200 * 1024]; + let stream = + futures::stream::iter((0..10).map(move |_| Ok::<_, std::io::Error>(chunk.clone()))); + let req = Request::builder() + .method("POST") + .uri("/v1/completions") + .header("authorization", "Bearer sk-caller") + .header("content-type", "application/json") + .body(Body::from_stream(stream)) + .unwrap(); + let resp = run(app, req).await; + assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE); + let bytes = to_bytes(resp.into_body(), 1024).await.unwrap(); + let v: serde_json::Value = serde_json::from_slice(&bytes) + .expect("413 must carry the JSON envelope, not axum's text/plain rejection"); + assert_eq!(v["error"]["type"], "invalid_request_error"); + } + + /// Same contract for the raw-`Bytes` handlers (batches / + /// fine-tuning). + #[tokio::test] + async fn chunked_oversize_on_v1_batches_returns_openai_envelope() { + let hub = Arc::new(Hub::new()); + let snap = seed_snapshot("my-gpt4", &["my-gpt4"], "http://unused"); + let app = build_router(build_state(snap, hub)); + + let chunk = vec![b'x'; 200 * 1024]; + let stream = + futures::stream::iter((0..10).map(move |_| Ok::<_, std::io::Error>(chunk.clone()))); + let req = Request::builder() + .method("POST") + .uri("/v1/batches") + .header("authorization", "Bearer sk-caller") + .header("content-type", "application/json") + .body(Body::from_stream(stream)) + .unwrap(); + let resp = run(app, req).await; + assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE); + let bytes = to_bytes(resp.into_body(), 1024).await.unwrap(); + let v: serde_json::Value = serde_json::from_slice(&bytes) + .expect("413 must carry the JSON envelope, not axum's text/plain rejection"); + assert_eq!(v["error"]["type"], "invalid_request_error"); + } + + /// Chunked oversize multipart upload: axum's `MultipartError` + /// classifies the cap hit as 413, and the handlers must preserve + /// that instead of folding every multipart error into 400. + #[tokio::test] + async fn chunked_oversize_multipart_returns_413_envelope() { + let hub = Arc::new(Hub::new()); + let snap = seed_snapshot("my-gpt4", &["my-gpt4"], "http://unused"); + let app = build_router(build_state(snap, hub)); + + let boundary = "aisix-test-boundary-413"; + let mut body = Vec::new(); + body.extend_from_slice( + format!( + "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; \ + filename=\"a.wav\"\r\nContent-Type: audio/wav\r\n\r\n" + ) + .as_bytes(), + ); + body.extend(vec![b'x'; 2 * 1024 * 1024]); // over the 1 MiB test cap + body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); + let chunks: Vec<_> = body.chunks(200 * 1024).map(|c| c.to_vec()).collect(); + let stream = futures::stream::iter(chunks.into_iter().map(Ok::<_, std::io::Error>)); + + let req = Request::builder() + .method("POST") + .uri("/v1/audio/transcriptions") + .header("authorization", "Bearer sk-caller") + .header( + "content-type", + format!("multipart/form-data; boundary={boundary}"), + ) + .body(Body::from_stream(stream)) + .unwrap(); + let resp = run(app, req).await; + assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE); + let bytes = to_bytes(resp.into_body(), 1024).await.unwrap(); + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["error"]["type"], "invalid_request_error"); + let message = v["error"]["message"].as_str().unwrap(); + assert!( + message.contains("limit"), + "413 message should reference the limit; got {message:?}" + ); + } + + /// The passthrough tunnel reads its body manually (`to_bytes`), so + /// the `0` sentinel has to be widened there too — this is the site + /// the first audit round caught unconverted, where every POST got + /// `413 request body exceeds 0-byte limit` on the new default. + #[tokio::test] + async fn zero_limit_passthrough_post_is_not_rejected() { + let hub = Arc::new(Hub::new()); + let snap = seed_snapshot("my-gpt4", &["my-gpt4"], "http://unused"); + let app = build_router(build_state_with_limit(snap, hub, 0)); + + let req = Request::builder() + .method("POST") + .uri("/passthrough/openai/v1/chat/completions") + .header("authorization", "Bearer sk-caller") + .header("content-type", "application/json") + .body(Body::from(r#"{"model":"gpt-4o","input":"hi"}"#)) + .unwrap(); + let resp = run(app, req).await; + assert_ne!( + resp.status(), + StatusCode::PAYLOAD_TOO_LARGE, + "limit 0 must not reject the passthrough body" + ); + // Past the body read; the dispatch then failed on the unusable + // upstream. + assert!( + resp.status().is_server_error(), + "expected an upstream dispatch failure, got {}", + resp.status() + ); + } + + /// With a configured cap, a chunked over-limit passthrough body is + /// a 413 in the envelope — and a transport fault stays a 400, no + /// longer mislabelled as `RequestTooLarge`. + #[tokio::test] + async fn chunked_oversize_on_passthrough_returns_openai_envelope() { + let hub = Arc::new(Hub::new()); + let snap = seed_snapshot("my-gpt4", &["my-gpt4"], "http://unused"); + let app = build_router(build_state(snap, hub)); + + let chunk = vec![b'x'; 200 * 1024]; + let stream = + futures::stream::iter((0..10).map(move |_| Ok::<_, std::io::Error>(chunk.clone()))); + let req = Request::builder() + .method("POST") + .uri("/passthrough/openai/v1/chat/completions") + .header("authorization", "Bearer sk-caller") + .header("content-type", "application/json") + .body(Body::from_stream(stream)) + .unwrap(); + let resp = run(app, req).await; + assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE); + let bytes = to_bytes(resp.into_body(), 1024).await.unwrap(); + let v: serde_json::Value = + serde_json::from_slice(&bytes).expect("413 must carry the JSON envelope"); + assert_eq!(v["error"]["type"], "invalid_request_error"); + let message = v["error"]["message"].as_str().unwrap(); + assert!( + message.contains("limit"), + "413 message should reference the limit; got {message:?}" + ); + } + /// Issue #159 companion: a body within the cap must NOT be /// rejected — the middleware short-circuits ONLY when the /// Content-Length exceeds the cap, leaving normal traffic diff --git a/crates/aisix-proxy/src/mcp.rs b/crates/aisix-proxy/src/mcp.rs index bbe98701..ee2452d1 100644 --- a/crates/aisix-proxy/src/mcp.rs +++ b/crates/aisix-proxy/src/mcp.rs @@ -122,8 +122,22 @@ async fn dispatch( // Buffer the body so the JSON-RPC method can be inspected, then rebuilt for // the gateway. The global body-limit layer has already capped the size. let (parts, body) = request.into_parts(); - let bytes = match to_bytes(body, state.request_body_limit_bytes).await { + let bytes = match to_bytes( + body, + crate::error::body_read_cap(state.request_body_limit_bytes), + ) + .await + { Ok(bytes) => bytes, + // A cap hit is a 413 in the standard envelope — consistent with + // what the Content-Length middleware already answers on this + // route; anything else reading the body is a client fault. + Err(err) if crate::error::is_length_limit_error(&err) => { + return crate::error::ProxyError::RequestTooLarge { + limit_bytes: state.request_body_limit_bytes, + } + .into_response(); + } Err(_) => return (StatusCode::BAD_REQUEST, "invalid request body").into_response(), }; @@ -250,7 +264,12 @@ async fn dispatch( // body is only buffered when a guardrail chain is attached. let response = if let Some(chain) = &guardrail_chain { let (resp_parts, resp_body) = response.into_parts(); - let resp_bytes = match to_bytes(resp_body, state.request_body_limit_bytes).await { + let resp_bytes = match to_bytes( + resp_body, + crate::error::body_read_cap(state.request_body_limit_bytes), + ) + .await + { Ok(bytes) => bytes, Err(_) => { return (StatusCode::BAD_GATEWAY, "invalid upstream response").into_response() @@ -892,6 +911,33 @@ mod tests { assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE, "got {status}"); } + #[tokio::test] + async fn chunked_oversized_body_returns_enveloped_413() { + // No Content-Length: the middleware can't pre-check, so the + // handler's own capped read fires. The length-limit error must + // surface as the enveloped 413 — matching what the middleware + // answers on this route — not the bare-400 "invalid request + // body" it used to fold into. + let router = router_with(snapshot_with_key()); + let chunk = vec![b'a'; 200 * 1024]; + let stream = + futures::stream::iter((0..10).map(move |_| Ok::<_, std::io::Error>(chunk.clone()))); + let req = HttpRequest::post("/mcp") + .header("host", "mcp.aisix.example.com") + .header("content-type", "application/json") + .header("authorization", format!("Bearer {TOKEN}")) + .body(Body::from_stream(stream)) + .unwrap(); + let resp = router.oneshot(req).await.expect("router responds"); + assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE); + let body = axum::body::to_bytes(resp.into_body(), 64 * 1024) + .await + .expect("read body"); + let v: serde_json::Value = + serde_json::from_slice(&body).expect("413 must carry the JSON envelope"); + assert_eq!(v["error"]["type"], "invalid_request_error"); + } + #[tokio::test] async fn authenticated_request_reaches_the_mcp_gateway() { let router = router_with(snapshot_with_key()); diff --git a/crates/aisix-proxy/src/passthrough.rs b/crates/aisix-proxy/src/passthrough.rs index 139164b2..fe41db56 100644 --- a/crates/aisix-proxy/src/passthrough.rs +++ b/crates/aisix-proxy/src/passthrough.rs @@ -353,11 +353,18 @@ async fn dispatch( // chunked / no-Content-Length / Content-Length-lying case once // the actual byte count exceeds the cap. let body_limit = state.request_body_limit_bytes; - let body_bytes: Bytes = axum::body::to_bytes(req.into_body(), body_limit) - .await - .map_err(|_| ProxyError::RequestTooLarge { - limit_bytes: body_limit, - })?; + let body_bytes: Bytes = + axum::body::to_bytes(req.into_body(), crate::error::body_read_cap(body_limit)) + .await + .map_err(|err| { + if crate::error::is_length_limit_error(&err) { + ProxyError::RequestTooLarge { + limit_bytes: body_limit, + } + } else { + ProxyError::InvalidRequest("failed to read request body".into()) + } + })?; // #911 [6]: run INPUT guardrails on the passthrough request body BEFORE it // reaches the upstream. The tunnel forwards arbitrary provider endpoints diff --git a/crates/aisix-proxy/src/rerank.rs b/crates/aisix-proxy/src/rerank.rs index b6a0e903..745e7baf 100644 --- a/crates/aisix-proxy/src/rerank.rs +++ b/crates/aisix-proxy/src/rerank.rs @@ -72,8 +72,20 @@ pub async fn rerank( State(state): State, auth: AuthenticatedKey, client: ClientContext, - Json(mut body): Json, + // Result-wrapped so an extractor-layer 413 maps to the OpenAI + // envelope — see completions.rs. + body: Result, axum::extract::rejection::JsonRejection>, ) -> Response { + let Json(mut body) = match body { + Ok(json) => json, + Err(rej) => { + return crate::error::proxy_error_from_json_rejection( + rej, + state.request_body_limit_bytes, + ) + .into_response(); + } + }; let started = Instant::now(); let request_id = client.request_id.clone(); let api_key_id = auth.entry.id.clone(); diff --git a/crates/aisix-proxy/src/responses.rs b/crates/aisix-proxy/src/responses.rs index 7f791fbe..2da0d35d 100644 --- a/crates/aisix-proxy/src/responses.rs +++ b/crates/aisix-proxy/src/responses.rs @@ -150,8 +150,20 @@ pub async fn responses( State(state): State, auth: AuthenticatedKey, client: ClientContext, - Json(mut body): Json, + // Result-wrapped so an extractor-layer 413 maps to the OpenAI + // envelope — see completions.rs. + body: Result, axum::extract::rejection::JsonRejection>, ) -> Response { + let Json(mut body) = match body { + Ok(json) => json, + Err(rej) => { + return crate::error::proxy_error_from_json_rejection( + rej, + state.request_body_limit_bytes, + ) + .into_response(); + } + }; let started = Instant::now(); let request_id = client.request_id.clone(); let api_key_id = auth.entry.id.clone(); diff --git a/tests/e2e/src/cases/body-edges-e2e.test.ts b/tests/e2e/src/cases/body-edges-e2e.test.ts index 9f1e5804..a8a8c6c4 100644 --- a/tests/e2e/src/cases/body-edges-e2e.test.ts +++ b/tests/e2e/src/cases/body-edges-e2e.test.ts @@ -278,3 +278,96 @@ describe("body edges e2e: multi-turn, oversize body, empty messages", () => { expect(upstreamChatHitsAfter).toBe(upstreamChatHitsBefore); }); }); + +// The shipped default is `request_body_limit_bytes: 0` — NO cap, matching +// the reference LLM proxy's out-of-box behaviour (its request-size guard +// defaults to off): providers accept larger requests than any fixed +// gateway default, so a gateway-side cap rejects requests the upstream +// would have served. The suite above pins the behaviour WITH a cap (the +// harness sets 10 MiB); this suite pins the default. +describe("body edges e2e: unlimited default (request_body_limit_bytes: 0)", () => { + let app: SpawnedApp | undefined; + let upstream: OpenAiUpstream | undefined; + let etcdReachable = false; + + beforeAll(async () => { + const etcd = new EtcdClient(); + etcdReachable = await etcd.ping(); + if (!etcdReachable) return; + + upstream = await startOpenAiUpstream(); + app = await spawnApp({ requestBodyLimitBytes: 0 }); + const seed = new SeedClient(etcd, app.etcdPrefix); + + const pk = await seed.createProviderKey({ + display_name: "body-unlimited-pk", + secret: "sk-mock", + api_base: `${upstream.baseUrl}/v1`, + }); + await seed.createModel({ + display_name: "body-unlimited", + provider: "openai", + model_name: "gpt-4o-mini", + provider_key_id: pk.id, + }); + await seed.createApiKey({ + key_hash: CALLER_KEY_HASH, + allowed_models: ["body-unlimited"], + }); + + const client = new OpenAI({ + apiKey: CALLER_PLAINTEXT, + baseURL: `${app.proxyUrl}/v1`, + maxRetries: 0, + }); + await waitConfigPropagation(async () => { + try { + const r = await client.chat.completions.create({ + model: "body-unlimited", + messages: [{ role: "user", content: "ready-probe" }], + }); + return r.choices[0]?.message.role === "assistant"; + } catch { + return false; + } + }); + }); + + afterAll(async () => { + await app?.exit(); + await upstream?.close(); + }); + + test("a 12 MiB body sails through to the upstream", async (ctx) => { + if (!etcdReachable || !app || !upstream) { + ctx.skip(); + return; + } + + // Comfortably above both the old 10 MiB default and axum's + // built-in 2 MiB extractor fallback — proving the cap is OFF, + // not merely raised. + const filler = "x".repeat(12 * 1024 * 1024); + const upstreamHitsBefore = upstream.receivedRequests.length; + const res = await fetch(`${app.proxyUrl}/v1/chat/completions`, { + method: "POST", + headers: { + authorization: `Bearer ${CALLER_PLAINTEXT}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + model: "body-unlimited", + messages: [{ role: "user", content: filler }], + }), + }); + + expect(res.status).toBe(200); + await res.json(); + expect(upstream.receivedRequests.length).toBe(upstreamHitsBefore + 1); + const sent = upstream.receivedRequests[upstreamHitsBefore]!; + const sentBody = JSON.parse(sent.body) as { + messages?: Array<{ content?: string }>; + }; + expect(sentBody.messages?.[0]?.content?.length).toBe(filler.length); + }, 60_000); +}); diff --git a/tests/e2e/src/harness/app.ts b/tests/e2e/src/harness/app.ts index 7103da42..7ef89c0a 100644 --- a/tests/e2e/src/harness/app.ts +++ b/tests/e2e/src/harness/app.ts @@ -42,6 +42,14 @@ export interface AppOverrides { recursive?: boolean; header?: string; }; + /** + * `proxy.request_body_limit_bytes`. A dedicated override (like + * `realIp`) because `extra` replaces whole top-level blocks and the + * proxy block carries the harness-picked listener addr. `0` disables + * the cap — the shipped default; the harness pins 10 MiB unless a + * test overrides it so the existing 413 suite keeps its subject. + */ + requestBodyLimitBytes?: number; /** * Extra environment variables for the spawned binary, applied AFTER the * `AISIX_*` strip. Use for non-config secrets the DP reads from its own @@ -189,7 +197,7 @@ async function spawnAppOnce(overrides: AppOverrides = {}): Promise { }), proxy: { addr: `127.0.0.1:${proxyPort}`, - request_body_limit_bytes: 10485760, + request_body_limit_bytes: overrides.requestBodyLimitBytes ?? 10485760, ...(overrides.realIp ? { real_ip: overrides.realIp } : {}), }, admin: adminEnabled