feat(image): make size/pixel limits configurable, double defaults
Some checks failed
CI / check (push) Failing after 6m23s
CI / build (push) Has been skipped

将 MAX_IMAGE_DIMENSION / MAX_IMAGE_PIXELS 从编译期 const 改为运行时
LazyLock(启动读环境变量),默认值 x2。

- 默认值:单边 4096→8192,总像素 25M→50M
- clamp 策略:只设下限防危险小值(DIMENSION≥512,PIXELS≥1M),
  超下限 clamp + warn;无上限,完全信任运维
- 内存影响:默认 50M 像素对应 ~200MB/图解码缓冲(max_alloc)
- 模式复用 WEBP_CONFIG:env→parse→(val,clamped)→clamp→warn→info
- 所有引用点解引用(*MAX_...),含 upload.rs/webp.rs 跨文件
- check_upload_dimensions 超限文案改为动态读取上限,不再硬编码数值
- 测试改用 *MAX_IMAGE_DIMENSION+1,数值无关

.env.example 与 AGENTS.md 同步更新环境变量说明。
This commit is contained in:
xfy 2026-06-24 13:54:36 +08:00
parent 933c7a8c93
commit ff2694c5a3
5 changed files with 101 additions and 40 deletions

View File

@ -103,15 +103,18 @@ COMPRESSION_ALGORITHMS=gzip,brotli,deflate,zstd
# - 处理变体:更改处理参数(查询串不同即视为新资源)。
# ─────────────────────────────────────────────────────────────
# 上传图片尺寸/像素上限(硬编码默认值,非环境变量
# 上传图片尺寸/像素上限(运行时环境变量,启动时读一次
# ─────────────────────────────────────────────────────────────
# 上传时在 header 阶段统一校验(JPEG/PNG/WebP/GIF 同一路径),超限直接 400 拒绝:
# 单边最大 4096px(MAX_IMAGE_DIMENSION,见 src/api/image.rs)
# 总像素最大约 2500 万(MAX_IMAGE_PIXELS,约 5000×5000)
# 超限返回:"图片尺寸过大,请压缩到 4096×4096 以内或单图不超过约 5000 万像素后再上传"。
# 这两个值同时也是图片解码的安全护栏(防超大图撑爆内存),不宜轻易下调。
# 如需调整,改 src/api/image.rs 的 const 后重新编译。
# 注:读取侧 GET /uploads/* 的动态处理(?w= 等)沿用同一上限,无参数取原图不查像素。
# 上传与动态处理(?w= 等)在 header 阶段统一校验(JPEG/PNG/WebP/GIF 同一路径),
# 超限直接 400 拒绝。这两个值同时也是图片解码的安全护栏(防超大图撑爆内存)。
# 只设下限防误调到危险小值,无上限(完全信任运维);低于下限会 clamp 并打 WARN。
# 注:读取侧 GET /uploads/* 的无参数取原图分支不查像素(只卡 20MB 字节)。
# 图片单边(宽或高)尺寸上限,单位像素。默认 8192,下限 512。
MAX_IMAGE_DIMENSION=8192
# 图片总像素上限。默认 50_000_000(约 7000×7000),下限 1_000_000。
# ⚠️ 此值决定单图解码内存缓冲(max_alloc = pixels × 4 + 1MB),
# 默认 50M 像素对应约 200MB/图;上调前确认部署环境内存与并发上传量。
MAX_IMAGE_PIXELS=50000000
# ─────────────────────────────────────────────────────────────
# 图片磁盘缓存上限uploads/.cache/,后台清理任务每小时扫描一次)

View File

@ -35,6 +35,8 @@ Optional tuning via env vars (all have sane defaults):
```
WEBP_QUALITY=85.0 # 0.0100.0, clamped
WEBP_METHOD=2 # 06, clamped
MAX_IMAGE_DIMENSION=8192 # max single side in px, min 512, no upper limit
MAX_IMAGE_PIXELS=50000000 # max total pixels (~7k×7k), min 1M, no upper limit
RATE_LIMIT_STRICT_PER_SEC=1
RATE_LIMIT_STRICT_BURST=5
RATE_LIMIT_UPLOAD_PER_SEC=2

View File

@ -44,12 +44,70 @@ fn etag_matches(if_none_match: &str, etag: &str) -> bool {
}
#[cfg(feature = "server")]
pub const MAX_IMAGE_DIMENSION: u32 = 4096;
/// 图片单边(宽或高)尺寸上限,单位像素。
///
/// 启动时从 `MAX_IMAGE_DIMENSION` 环境变量读取,默认 8192。
/// 只设下限 512防误调到危险小值导致正常图都传不上无上限完全信任运维
/// 低于下限时 clamp 回 512 并打 WARN。
pub static MAX_IMAGE_DIMENSION: LazyLock<u32> = LazyLock::new(|| {
const DEFAULT: u32 = 8192;
const MIN: u32 = 512;
let (val, clamped) = std::env::var("MAX_IMAGE_DIMENSION")
.ok()
.and_then(|s| s.parse::<u32>().ok())
.map(|v| {
if v < MIN {
(MIN, true)
} else {
(v, false)
}
})
.unwrap_or((DEFAULT, false));
if clamped {
tracing::warn!(
"MAX_IMAGE_DIMENSION was clamped from {} to {} (minimum {})",
std::env::var("MAX_IMAGE_DIMENSION").unwrap_or_default(),
val,
MIN
);
}
tracing::info!("Image dimension limit loaded: {}", val);
val
});
#[cfg(feature = "server")]
const DEFAULT_JPEG_QUALITY: u8 = 85;
#[cfg(feature = "server")]
/// 允许处理的最大图片像素数(约 5k x 5k
pub const MAX_IMAGE_PIXELS: u32 = 25_000_000; // ~5k x 5k
/// 允许处理的最大图片像素数(默认约 7k x 7k
///
/// 启动时从 `MAX_IMAGE_PIXELS` 环境变量读取,默认 50_000_000。
/// 只设下限 1_000_000防误调无上限。
/// ⚠️ 此值同时决定单图解码内存缓冲max_alloc = pixels × 4 + 1MB
/// 默认 50M 像素对应约 200MB/图,上调前确认部署环境内存。
pub static MAX_IMAGE_PIXELS: LazyLock<u32> = LazyLock::new(|| {
const DEFAULT: u32 = 50_000_000;
const MIN: u32 = 1_000_000;
let (val, clamped) = std::env::var("MAX_IMAGE_PIXELS")
.ok()
.and_then(|s| s.parse::<u32>().ok())
.map(|v| {
if v < MIN {
(MIN, true)
} else {
(v, false)
}
})
.unwrap_or((DEFAULT, false));
if clamped {
tracing::warn!(
"MAX_IMAGE_PIXELS was clamped from {} to {} (minimum {})",
std::env::var("MAX_IMAGE_PIXELS").unwrap_or_default(),
val,
MIN
);
}
tracing::info!("Image pixel limit loaded: {}", val);
val
});
#[cfg(feature = "server")]
#[derive(Debug, Clone)]
@ -122,12 +180,12 @@ impl ImageParams {
/// 校验参数合法性,返回 HTTP 400 状态码表示非法。
fn validate(&self) -> Result<(), StatusCode> {
if let Some(dim) = self.w {
if dim == 0 || dim > MAX_IMAGE_DIMENSION {
if dim == 0 || dim > *MAX_IMAGE_DIMENSION {
return Err(StatusCode::BAD_REQUEST);
}
}
if let Some(dim) = self.h {
if dim == 0 || dim > MAX_IMAGE_DIMENSION {
if dim == 0 || dim > *MAX_IMAGE_DIMENSION {
return Err(StatusCode::BAD_REQUEST);
}
}
@ -148,7 +206,7 @@ impl ImageParams {
}
let tw: u32 = parts[0].parse().map_err(|_| StatusCode::BAD_REQUEST)?;
let th: u32 = parts[1].parse().map_err(|_| StatusCode::BAD_REQUEST)?;
if tw == 0 || th == 0 || tw > MAX_IMAGE_DIMENSION || th > MAX_IMAGE_DIMENSION {
if tw == 0 || th == 0 || tw > *MAX_IMAGE_DIMENSION || th > *MAX_IMAGE_DIMENSION {
return Err(StatusCode::BAD_REQUEST);
}
}
@ -241,13 +299,13 @@ fn check_image_dimensions(width: u32, height: u32) -> Result<(), StatusCode> {
return Err(StatusCode::BAD_REQUEST);
}
let pixels = u64::from(width) * u64::from(height);
if pixels > u64::from(MAX_IMAGE_PIXELS) {
if pixels > u64::from(*MAX_IMAGE_PIXELS) {
tracing::warn!(
"Image dimensions too large: {}x{} ({} pixels, max {})",
width,
height,
pixels,
MAX_IMAGE_PIXELS
*MAX_IMAGE_PIXELS
);
return Err(StatusCode::PAYLOAD_TOO_LARGE);
}
@ -270,22 +328,19 @@ pub fn check_upload_dimensions(data: &[u8], mime_type: &str) -> Result<(), &'sta
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)
{
let max_dim = *MAX_IMAGE_DIMENSION;
let max_pixels = *MAX_IMAGE_PIXELS;
if width > max_dim || height > max_dim || pixels > u64::from(max_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 万像素后再上传",
max_dim,
max_dim,
max_pixels
);
return Err("图片尺寸过大,请压缩后再上传");
}
Ok(())
}
@ -323,9 +378,9 @@ fn read_dimensions_by_mime(
#[cfg(feature = "server")]
fn image_reader_limits() -> image::Limits {
let mut limits = image::Limits::default();
limits.max_image_width = Some(MAX_IMAGE_DIMENSION);
limits.max_image_height = Some(MAX_IMAGE_DIMENSION);
limits.max_alloc = Some(MAX_IMAGE_PIXELS as u64 * 4 + 1024 * 1024);
limits.max_image_width = Some(*MAX_IMAGE_DIMENSION);
limits.max_image_height = Some(*MAX_IMAGE_DIMENSION);
limits.max_alloc = Some(*MAX_IMAGE_PIXELS as u64 * 4 + 1024 * 1024);
limits
}
@ -363,7 +418,7 @@ fn process_image(
if parts.len() == 2 {
let tw: u32 = parts[0].parse().map_err(|_| StatusCode::BAD_REQUEST)?;
let th: u32 = parts[1].parse().map_err(|_| StatusCode::BAD_REQUEST)?;
if tw > 0 && th > 0 && tw <= MAX_IMAGE_DIMENSION && th <= MAX_IMAGE_DIMENSION {
if tw > 0 && th > 0 && tw <= *MAX_IMAGE_DIMENSION && th <= *MAX_IMAGE_DIMENSION {
img = img.thumbnail(tw, th);
}
}
@ -728,22 +783,23 @@ mod tests {
#[test]
fn check_upload_dimensions_accepts_boundary_png() {
// 恰好 4096×4096(约 16.7M 像素,低于 25M 上限)应放行
let data = make_png_bytes(MAX_IMAGE_DIMENSION, MAX_IMAGE_DIMENSION);
// 用 7000×7000(≈49M 像素):单边 7000 < 默认 8192 上限,且总像素 < 默认 50M 上限,应放行。
// 注意不能直接用 *MAX_IMAGE_DIMENSION 做正方形边——8192²≈67M 会触发像素上限拒绝。
let data = make_png_bytes(7000, 7000);
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);
// 单边超限:(上限+1)×1,像素远低于上限,但单边越界
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 data = make_png_bytes(1, *MAX_IMAGE_DIMENSION + 1);
let err = check_upload_dimensions(&data, "image/png").unwrap_err();
assert!(err.contains("尺寸过大"));
}
@ -808,7 +864,7 @@ mod tests {
#[test]
fn image_params_validate_oversized_width_rejected() {
let params = ImageParams {
w: Some(5000),
w: Some(*MAX_IMAGE_DIMENSION + 1),
..Default::default()
};
assert!(params.validate().is_err());

View File

@ -273,9 +273,9 @@ pub async fn upload_image(
};
let mut reader = image::ImageReader::with_format(cursor, format);
let mut limits = image::Limits::default();
limits.max_image_width = Some(crate::api::image::MAX_IMAGE_DIMENSION);
limits.max_image_height = Some(crate::api::image::MAX_IMAGE_DIMENSION);
limits.max_alloc = Some(crate::api::image::MAX_IMAGE_PIXELS as u64 * 4 + 1024 * 1024);
limits.max_image_width = Some(*crate::api::image::MAX_IMAGE_DIMENSION);
limits.max_image_height = Some(*crate::api::image::MAX_IMAGE_DIMENSION);
limits.max_alloc = Some(*crate::api::image::MAX_IMAGE_PIXELS as u64 * 4 + 1024 * 1024);
reader.limits(limits);
match reader.decode() {

View File

@ -148,7 +148,7 @@ pub fn decode(data: &[u8]) -> Result<image::DynamicImage, WebpError> {
let pixel_count = (width as u64) * (height as u64);
// 超过最大允许像素数时提前拒绝
if pixel_count > crate::api::image::MAX_IMAGE_PIXELS as u64 {
if pixel_count > *crate::api::image::MAX_IMAGE_PIXELS as u64 {
return Err(WebpError::Decode(format!(
"Image dimensions {}x{} exceed maximum allowed pixels",
width, height