fix(upload): unify image size limit across all formats

上传入口此前对超大图片行为不一致:JPEG/PNG 因 decode limits 失败后
静默存原图,WebP 报"损坏",GIF 完全不查。统一为 header 阶段硬拒绝。

- image.rs: 新增 check_upload_dimensions,按 MIME 只读 header 拿尺寸
  (WebP 走 zenwebp header,JPEG/PNG/GIF 走 image into_dimensions),
  超 MAX_IMAGE_DIMENSION/MAX_IMAGE_PIXELS 返回友好提示
- upload.rs: magic bytes 校验后插入统一尺寸校验,三种格式同路径
- upload.rs: JPEG/PNG 转码 fallback 的 Err(_) 改为 Err(e) 带原因日志
  (超限已在 header 阶段拦截,此处仅剩真损坏兜底)

读取侧 serve_image 不动;webp::decode 内的像素校验保留(读取侧仍需)。
This commit is contained in:
xfy 2026-06-24 13:25:23 +08:00
parent eed0b24ec3
commit 0777fd469a
2 changed files with 153 additions and 2 deletions

View File

@ -254,6 +254,72 @@ fn check_image_dimensions(width: u32, height: u32) -> Result<(), StatusCode> {
Ok(())
}
#[cfg(feature = "server")]
/// 仅读取 header 校验上传图片的尺寸/像素是否超限。
///
/// 与 `check_image_dimensions` 的区别:
/// - 输入是原始字节 + MIME,内部按 MIME 分发只解析 header 拿尺寸(不解码像素)
/// - 返回带友好中文提示的 `&'static str`(供上传接口直接回给用户)
///
/// 上传入口三种格式在此统一拦截至尺寸上限;WebP 走 `zenwebp` header,
/// JPEG/PNG/GIF 走 `image` crate 的 `into_dimensions`(均只读 header)。
pub fn check_upload_dimensions(data: &[u8], mime_type: &str) -> Result<(), &'static str> {
let dims = read_dimensions_by_mime(data, mime_type)?;
let (width, height) = dims;
if width == 0 || height == 0 {
return Err("图片文件损坏或格式不正确");
}
let pixels = u64::from(width) * u64::from(height);
if width > MAX_IMAGE_DIMENSION
|| height > MAX_IMAGE_DIMENSION
|| pixels > u64::from(MAX_IMAGE_PIXELS)
{
tracing::warn!(
"Uploaded image too large: {}x{} ({} pixels, max {}x{} / {} pixels)",
width,
height,
pixels,
MAX_IMAGE_DIMENSION,
MAX_IMAGE_DIMENSION,
MAX_IMAGE_PIXELS
);
return Err(
"图片尺寸过大,请压缩到 4096×4096 以内或单图不超过约 5000 万像素后再上传",
);
}
Ok(())
}
#[cfg(feature = "server")]
/// 按 MIME 只读 header 拿 (width, height)。失败返回损坏错误。
fn read_dimensions_by_mime(
data: &[u8],
mime_type: &str,
) -> Result<(u32, u32), &'static str> {
match mime_type {
"image/webp" => {
// zenwebp 的 WebPDecoder::build 只解析 RIFF header,不解码像素(与 webp::decode 同源)。
let decoder = zenwebp::WebPDecoder::build(data)
.map_err(|_| "图片文件损坏或格式不正确")?;
let info = decoder.info();
Ok((info.width, info.height))
}
"image/jpeg" | "image/png" | "image/gif" => {
let format = match mime_type {
"image/jpeg" => image::ImageFormat::Jpeg,
"image/png" => image::ImageFormat::Png,
_ => image::ImageFormat::Gif,
};
let reader = image::ImageReader::with_format(std::io::Cursor::new(data), format);
reader
.into_dimensions()
.map_err(|_| "图片文件损坏或格式不正确")
}
// 上游已用 ALLOWED_MIME_TYPES 白名单拦截,理论不到这里
_ => Err("图片文件损坏或格式不正确"),
}
}
#[cfg(feature = "server")]
fn image_reader_limits() -> image::Limits {
let mut limits = image::Limits::default();
@ -644,6 +710,77 @@ mod tests {
assert_eq!(dims, None);
}
// —— check_upload_dimensions:统一上传尺寸/像素上限校验 ——
/// 构造指定尺寸的 PNG 字节(内存占用 = 尺寸,仅用于 header 校验测试)。
fn make_png_bytes(w: u32, h: u32) -> Vec<u8> {
let img = image::DynamicImage::new_rgb8(w, h);
let mut buf = std::io::Cursor::new(Vec::new());
img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
buf.into_inner()
}
#[test]
fn check_upload_dimensions_accepts_small_png() {
let data = make_png_bytes(100, 100);
assert!(check_upload_dimensions(&data, "image/png").is_ok());
}
#[test]
fn check_upload_dimensions_accepts_boundary_png() {
// 恰好 4096×4096(约 16.7M 像素,低于 25M 上限)应放行
let data = make_png_bytes(MAX_IMAGE_DIMENSION, MAX_IMAGE_DIMENSION);
assert!(check_upload_dimensions(&data, "image/png").is_ok());
}
#[test]
fn check_upload_dimensions_rejects_oversized_width() {
// 单边超限:5000×1(像素仅 5000,远低于 25M,但单边 >4096)
let data = make_png_bytes(MAX_IMAGE_DIMENSION + 1, 1);
let err = check_upload_dimensions(&data, "image/png").unwrap_err();
assert!(err.contains("尺寸过大"));
}
#[test]
fn check_upload_dimensions_rejects_oversized_height() {
let data = make_png_bytes(1, MAX_IMAGE_DIMENSION + 1);
let err = check_upload_dimensions(&data, "image/png").unwrap_err();
assert!(err.contains("尺寸过大"));
}
#[test]
fn check_upload_dimensions_accepts_small_webp() {
let img = image::DynamicImage::new_rgb8(64, 48);
let webp_bytes = crate::webp::encode(&img, 85.0, 2).unwrap();
assert!(check_upload_dimensions(&webp_bytes, "image/webp").is_ok());
}
#[test]
fn check_upload_dimensions_accepts_gif() {
// image crate 默认启用 gif feature,into_dimensions 可读 GIF header
let img = image::DynamicImage::new_rgb8(32, 32);
let mut buf = std::io::Cursor::new(Vec::new());
img.write_to(&mut buf, image::ImageFormat::Gif).unwrap();
assert!(check_upload_dimensions(&buf.into_inner(), "image/gif").is_ok());
}
#[test]
fn check_upload_dimensions_rejects_corrupt_bytes() {
// 非 magic bytes,与现有损坏文件校验文案对齐
let err = check_upload_dimensions(b"not an image at all", "image/png").unwrap_err();
assert_eq!(err, "图片文件损坏或格式不正确");
}
#[test]
fn read_dimensions_by_mime_dispatches_webp() {
let img = image::DynamicImage::new_rgb8(16, 9);
let webp_bytes = crate::webp::encode(&img, 85.0, 2).unwrap();
assert_eq!(
read_dimensions_by_mime(&webp_bytes, "image/webp").unwrap(),
(16, 9)
);
}
#[test]
fn image_params_validate_valid_defaults() {
let params = ImageParams::default();

View File

@ -208,6 +208,19 @@ pub async fn upload_image(
));
}
// 仅读 header 统一校验尺寸/像素上限。三种格式走同一路径:
// JPEG/PNG/GIF 用 image crate 的 into_dimensions,WebP 用 zenwebp header。
// 超限直接拒绝,避免大图走 decode 后被静默降级(原 fallback 存原图)。
if let Err(msg) = crate::api::image::check_upload_dimensions(&data, mime_type.as_str()) {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({
"success": false,
"error": msg
})),
));
}
let is_gif = mime_type.as_str() == "image/gif";
let is_webp = mime_type.as_str() == "image/webp";
@ -299,8 +312,9 @@ pub async fn upload_image(
};
result
}
Err(_) => {
tracing::warn!("Failed to decode image, keeping original format");
Err(e) => {
// 到这里尺寸校验已通过(超限在 header 阶段被拒),decode 失败只能是真损坏。
tracing::warn!("Failed to decode image ({}), keeping original format", e);
(original_data, mime_to_ext(&mime).to_string(), false)
}
}