From 6764950aecb08ede58d6d6be1d53826fd0f89b2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 12 Sep 2026 16:27:23 +0800 Subject: [PATCH] =?UTF-8?q?=E9=99=90=E5=88=B6=E5=8E=9F=E5=A7=8B=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E4=B8=8A=E4=BC=A0=E5=AD=97=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 image 和 mask multipart 字段改为分块读取并限制单字段大小。 新增图片总字节上限、413 错误和字段/总量边界测试。 同步 Raw GPT Image 2 代理技术方案的资源防护合同。 --- ...案】Raw GPT Image 2图片编辑代理-2026-09-07.md | 2 +- server-rs/crates/api-server/src/raw_image.rs | 128 +++++++++++++++++- 2 files changed, 123 insertions(+), 7 deletions(-) diff --git a/docs/technical/【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md b/docs/technical/【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md index 393eb6fb4..81f44f5e7 100644 --- a/docs/technical/【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md +++ b/docs/technical/【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md @@ -38,7 +38,7 @@ height: 1024 校验通过后按整数尺寸发送给 provider,不静默 clamp 或改写调用者尺寸。 -Raw 路由的 multipart body limit 为 `64 MiB`,覆盖图片和文本字段;图片字段由 multipart 解析器收集为可共享字节缓冲,文本字段按 chunk 流式读取并在达到 `16 KiB` 时立即拒绝,随后在阻塞线程中完成 PNG 解码。PNG 仅接受 8-bit/channel(`png::BitDepth::Eight`),其它位深在解码前以 400 返回“`{field} 必须为 8-bit PNG(每通道 8 位)`”;PNG 解码使用与输出合同一致的资源上限:宽高各不超过 `3840`,解码分配不超过 `8294400 × 4` 字节;不再执行 base64 入站解码。 +Raw 路由的 multipart body limit 为 `64 MiB`,覆盖图片和文本字段;每个 `image` / `mask` 字段最多 `32 MiB`,两者图片字节总计最多 `48 MiB`,图片字段按 chunk 流式收集,越过任一上限立即返回 `413`,文本字段按 chunk 流式读取并在达到 `16 KiB` 时立即拒绝。随后图片校验在独立的 raw-image 解码 semaphore(进程内最多 4 个 blocking 解码任务)中执行,并受 30 秒本地处理截止时间约束;超时只停止等待,不会提前释放仍在运行任务持有的槽位。PNG 仅接受 8-bit/channel(`png::BitDepth::Eight`),其它位深在解码前以 400 返回“`{field} 必须为 8-bit PNG(每通道 8 位)`”;PNG 解码使用与输出合同一致的资源上限:宽高各不超过 `3840`,解码分配不超过 `8294400 × 4` 字节;不再执行 base64 入站解码。 服务端发送给 `platform-image` 时固定注入: diff --git a/server-rs/crates/api-server/src/raw_image.rs b/server-rs/crates/api-server/src/raw_image.rs index c6bdf1d65..30932c3a1 100644 --- a/server-rs/crates/api-server/src/raw_image.rs +++ b/server-rs/crates/api-server/src/raw_image.rs @@ -61,6 +61,8 @@ pub(crate) struct RawImageEditResponse { } const RAW_IMAGE_MAX_TEXT_FIELD_BYTES: usize = 16 * 1024; +const RAW_IMAGE_MAX_FILE_BYTES: usize = 32 * 1024 * 1024; +const RAW_IMAGE_MAX_INPUT_BYTES: usize = 48 * 1024 * 1024; const RAW_IMAGE_PREPARE_TIMEOUT: Duration = Duration::from_secs(30); pub(crate) async fn edit_raw_image( @@ -219,6 +221,7 @@ async fn parse_multipart_request( let mut output_format = None; let mut width = None; let mut height = None; + let mut image_bytes_total = 0usize; while let Some(field) = multipart.next_field().await.map_err(|error| { tracing::warn!(error = %error, "raw image multipart 字段解析失败"); @@ -233,13 +236,13 @@ async fn parse_multipart_request( if image.is_some() { return Err(bad_request("image 字段不能重复")); } - image = Some(read_multipart_image(field, "image").await?); + image = Some(read_multipart_image(field, "image", &mut image_bytes_total).await?); } "mask" => { if mask.is_some() { return Err(bad_request("mask 字段不能重复")); } - mask = Some(read_multipart_image(field, "mask").await?); + mask = Some(read_multipart_image(field, "mask", &mut image_bytes_total).await?); } "prompt" => set_text_field(&mut prompt, field, "prompt").await?, "quality" => set_text_field(&mut quality, field, "quality").await?, @@ -269,27 +272,79 @@ async fn parse_multipart_request( } async fn read_multipart_image( - field: axum::extract::multipart::Field<'_>, + mut field: axum::extract::multipart::Field<'_>, name: &str, + total_bytes: &mut usize, ) -> Result { let mime_type = field.content_type().unwrap_or_default().to_string(); if !mime_type.eq_ignore_ascii_case("image/png") { return Err(bad_request(format!("{name} 必须为 image/png"))); } - let bytes = field.bytes().await.map_err(|error| { + let mut bytes = Vec::new(); + let mut field_bytes = 0usize; + while let Some(chunk) = field.chunk().await.map_err(|error| { tracing::warn!(field = name, error = %error, "raw image multipart 图片读取失败"); bad_request(format!("{name} 字段读取失败")) - })?; + })? { + append_bounded_image_chunk( + &mut bytes, + &mut field_bytes, + total_bytes, + &chunk, + name, + RAW_IMAGE_MAX_FILE_BYTES, + RAW_IMAGE_MAX_INPUT_BYTES, + )?; + } if bytes.is_empty() { return Err(bad_request(format!("{name} 文件不能为空"))); } Ok(RawImageData { - bytes, + bytes: Bytes::from(bytes), mime_type: "image/png".to_string(), file_name: format!("{name}.png"), }) } +fn append_bounded_image_chunk( + bytes: &mut Vec, + field_bytes: &mut usize, + total_bytes: &mut usize, + chunk: &[u8], + name: &str, + max_field_bytes: usize, + max_total_bytes: usize, +) -> Result<(), AppError> { + let next_field_bytes = field_bytes.saturating_add(chunk.len()); + if next_field_bytes > max_field_bytes { + tracing::warn!( + field = name, + bytes = next_field_bytes, + max_bytes = max_field_bytes, + "raw image multipart 图片字段超过大小限制" + ); + return Err(payload_too_large(format!( + "{name} 图片字段不能超过 {max_field_bytes} 字节" + ))); + } + let next_total_bytes = total_bytes.saturating_add(chunk.len()); + if next_total_bytes > max_total_bytes { + tracing::warn!( + field = name, + bytes = next_total_bytes, + max_bytes = max_total_bytes, + "raw image multipart 图片总输入超过大小限制" + ); + return Err(payload_too_large(format!( + "image 和 mask 图片总大小不能超过 {max_total_bytes} 字节" + ))); + } + bytes.extend_from_slice(chunk); + *field_bytes = next_field_bytes; + *total_bytes = next_total_bytes; + Ok(()) +} + async fn set_text_field( target: &mut Option, mut field: axum::extract::multipart::Field<'_>, @@ -510,6 +565,15 @@ fn bad_request(message: impl Into) -> AppError { })) } +fn payload_too_large(message: impl Into) -> AppError { + AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE).with_details(json!({ + "provider": "raw-image-edit", + "message": message.into(), + "maxFileBytes": RAW_IMAGE_MAX_FILE_BYTES, + "maxInputBytes": RAW_IMAGE_MAX_INPUT_BYTES, + })) +} + fn raw_image_prepare_timeout_error(message: &str) -> AppError { AppError::from_status(StatusCode::GATEWAY_TIMEOUT).with_details(json!({ "provider": "raw-image-edit", @@ -699,4 +763,56 @@ mod tests { assert!(format!("{error:?}").contains("prompt 不能超过 16384 字节")); } + + #[test] + fn image_chunks_enforce_field_and_total_limits() { + let mut bytes = Vec::new(); + let mut field_bytes = 0; + let mut total_bytes = 0; + append_bounded_image_chunk( + &mut bytes, + &mut field_bytes, + &mut total_bytes, + b"abc", + "image", + 3, + 8, + ) + .expect("chunk at field limit should pass"); + assert_eq!(bytes, b"abc"); + assert_eq!(field_bytes, 3); + assert_eq!(total_bytes, 3); + + let error = append_bounded_image_chunk( + &mut bytes, + &mut field_bytes, + &mut total_bytes, + b"d", + "image", + 3, + 8, + ) + .expect_err("chunk over field limit should fail"); + assert_eq!(error.status_code(), StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!(bytes, b"abc"); + assert_eq!(field_bytes, 3); + assert_eq!(total_bytes, 3); + + let mut other_field = Vec::new(); + let mut other_field_bytes = 0; + let error = append_bounded_image_chunk( + &mut other_field, + &mut other_field_bytes, + &mut total_bytes, + b"123456", + "mask", + 8, + 8, + ) + .expect_err("chunk over total limit should fail"); + assert_eq!(error.status_code(), StatusCode::PAYLOAD_TOO_LARGE); + assert!(other_field.is_empty()); + assert_eq!(other_field_bytes, 0); + assert_eq!(total_bytes, 3); + } }