限制原始图片上传字节

将 image 和 mask multipart 字段改为分块读取并限制单字段大小。

新增图片总字节上限、413 错误和字段/总量边界测试。

同步 Raw GPT Image 2 代理技术方案的资源防护合同。
This commit is contained in:
2026-09-12 16:27:23 +08:00
parent 0944667c37
commit 6764950aec
2 changed files with 123 additions and 7 deletions
@@ -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` 时固定注入:
+122 -6
View File
@@ -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<RawImageData, AppError> {
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<u8>,
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<String>,
mut field: axum::extract::multipart::Field<'_>,
@@ -510,6 +565,15 @@ fn bad_request(message: impl Into<String>) -> AppError {
}))
}
fn payload_too_large(message: impl Into<String>) -> 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);
}
}