限制原始图片上传字节
将 image 和 mask multipart 字段改为分块读取并限制单字段大小。 新增图片总字节上限、413 错误和字段/总量边界测试。 同步 Raw GPT Image 2 代理技术方案的资源防护合同。
This commit is contained in:
@@ -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` 时固定注入:
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user