Pictures: at most 16 megapixels and 64 MB decoded per upload (security M-3)
AC-4.4 allowed 8000 x 8000 pixels: a few kilobytes of flat PNG or WebP decode to 256 MB RGBA, more than the server container's 160 MiB. Now (proposed D-114) the header must also give at most 16 000 000 pixels and at most 64 MB decoded (16 MP x 4 bytes; a 16-bit image is refused above 8 MP), checked before decoding, with the image crate's decoder limits set as well; refused with the existing image_invalid. The decoded image is dropped before the square is converted, and the square converts in place (into_rgba8/into_rgb8), so the peak is one decoded image plus one square. Integration test at and over the pixel cap; unit test of the size rules. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
//! Pictures: E-9 upload, E-10 remove, E-11 download (FR-4, AC-4.4).
|
||||
//!
|
||||
//! An upload is JPEG, PNG or WebP (by `Content-Type`), at most 5 MiB (the body limit in `router.rs`) and 8000 × 8000
|
||||
//! pixels. The server applies the EXIF orientation, centre-crops to a square, flattens any transparency onto white,
|
||||
//! An upload is JPEG, PNG or WebP (by `Content-Type`), at most 5 MiB (the body limit in `router.rs`), 8000 × 8000
|
||||
//! pixels and 16 megapixels in all (D-114), and decodes to at most [`MAX_DECODE_ALLOC`] bytes, so a small
|
||||
//! file that decodes to a huge image is refused before it can exhaust the server's memory. The server applies the
|
||||
//! EXIF orientation, centre-crops to a square, flattens any transparency onto white,
|
||||
//! and re-encodes as JPEG quality 85 at 512 × 512 and 128 × 128 (no metadata is written). Files are
|
||||
//! `pictures/<user_id>/<picture_version>-512.jpg` and `-128.jpg`; older versions are deleted after a successful
|
||||
//! upload (SPEC §10.4).
|
||||
@@ -21,7 +23,7 @@ use axum::response::{IntoResponse, Response};
|
||||
use image::codecs::jpeg::JpegEncoder;
|
||||
use image::imageops::FilterType;
|
||||
use image::metadata::Orientation;
|
||||
use image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader, Rgb, RgbImage};
|
||||
use image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader, Limits, Rgb, RgbImage};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::AppState;
|
||||
@@ -37,6 +39,11 @@ use crate::wire::{IntSchema, JpegBytes, PictureSizeSchema, PngBytes, User, UuidS
|
||||
|
||||
/// Largest accepted width and height (AC-4.4).
|
||||
pub const MAX_DIMENSION: u32 = 8000;
|
||||
/// Largest accepted pixel count, width × height (D-114: 16 megapixels, for example 4000 × 4000).
|
||||
pub const MAX_PIXELS: u64 = 16_000_000;
|
||||
/// Most bytes an upload may decode to (D-114): one 8-bit RGBA image of [`MAX_PIXELS`]. A 16-bit image needs twice
|
||||
/// that per pixel and is refused above 8 megapixels.
|
||||
pub const MAX_DECODE_ALLOC: u64 = MAX_PIXELS * 4;
|
||||
/// JPEG quality of the stored pictures (AC-4.4).
|
||||
pub const JPEG_QUALITY: u8 = 85;
|
||||
/// The stored sizes (AC-4.4); E-11 serves 512 by default.
|
||||
@@ -62,23 +69,51 @@ pub fn format_of(content_type: Option<&str>) -> Option<ImageFormat> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes, orients, crops and re-encodes an upload. Returns the JPEG bytes for each of [`SIZES`], in that order.
|
||||
pub fn process(bytes: &[u8], format: ImageFormat) -> Result<Vec<Vec<u8>>, AppError> {
|
||||
let reader = ImageReader::with_format(Cursor::new(bytes), format);
|
||||
let mut decoder = reader.into_decoder().map_err(image_invalid)?;
|
||||
let (width, height) = decoder.dimensions();
|
||||
if width == 0 || height == 0 || width > MAX_DIMENSION || height > MAX_DIMENSION {
|
||||
/// The decoder limits of an upload (D-114).
|
||||
fn decode_limits() -> Limits {
|
||||
let mut limits = Limits::default();
|
||||
limits.max_image_width = Some(MAX_DIMENSION);
|
||||
limits.max_image_height = Some(MAX_DIMENSION);
|
||||
limits.max_alloc = Some(MAX_DECODE_ALLOC);
|
||||
limits
|
||||
}
|
||||
|
||||
/// The size rules of an upload, from its header (D-114): 1 to 8000 pixels each way, at most [`MAX_PIXELS`], and at
|
||||
/// most [`MAX_DECODE_ALLOC`] bytes decoded (`total_bytes`; decoders do not all enforce the allocation limit).
|
||||
fn check_size(width: u32, height: u32, total_bytes: u64) -> Result<(), AppError> {
|
||||
if width == 0
|
||||
|| height == 0
|
||||
|| width > MAX_DIMENSION
|
||||
|| height > MAX_DIMENSION
|
||||
|| u64::from(width) * u64::from(height) > MAX_PIXELS
|
||||
{
|
||||
return Err(image_invalid(format!("{width} × {height} pixels")));
|
||||
}
|
||||
let orientation = decoder.orientation().unwrap_or(Orientation::NoTransforms);
|
||||
let mut image = DynamicImage::from_decoder(decoder).map_err(image_invalid)?;
|
||||
image.apply_orientation(orientation);
|
||||
if total_bytes > MAX_DECODE_ALLOC {
|
||||
return Err(image_invalid(format!("{total_bytes} bytes decoded")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let (w, h) = (image.width(), image.height());
|
||||
let side = w.min(h);
|
||||
let square = image.crop_imm((w - side) / 2, (h - side) / 2, side, side);
|
||||
/// Decodes, orients, crops and re-encodes an upload. Returns the JPEG bytes for each of [`SIZES`], in that order.
|
||||
pub fn process(bytes: &[u8], format: ImageFormat) -> Result<Vec<Vec<u8>>, AppError> {
|
||||
let mut reader = ImageReader::with_format(Cursor::new(bytes), format);
|
||||
reader.limits(decode_limits());
|
||||
let mut decoder = reader.into_decoder().map_err(image_invalid)?;
|
||||
let (width, height) = decoder.dimensions();
|
||||
check_size(width, height, decoder.total_bytes())?;
|
||||
let orientation = decoder.orientation().unwrap_or(Orientation::NoTransforms);
|
||||
let square = {
|
||||
let mut image = DynamicImage::from_decoder(decoder).map_err(image_invalid)?;
|
||||
image.apply_orientation(orientation);
|
||||
let (w, h) = (image.width(), image.height());
|
||||
let side = w.min(h);
|
||||
// The decoded image is dropped at the end of this block, before the square is converted.
|
||||
image.crop_imm((w - side) / 2, (h - side) / 2, side, side)
|
||||
};
|
||||
let side = square.width();
|
||||
let square = if square.color().has_alpha() {
|
||||
let rgba = square.to_rgba8();
|
||||
let rgba = square.into_rgba8();
|
||||
let flat = RgbImage::from_fn(side, side, |x, y| {
|
||||
let p = rgba.get_pixel(x, y).0;
|
||||
let a = u32::from(p[3]);
|
||||
@@ -88,7 +123,7 @@ pub fn process(bytes: &[u8], format: ImageFormat) -> Result<Vec<Vec<u8>>, AppErr
|
||||
});
|
||||
DynamicImage::ImageRgb8(flat)
|
||||
} else {
|
||||
DynamicImage::ImageRgb8(square.to_rgb8())
|
||||
DynamicImage::ImageRgb8(square.into_rgb8())
|
||||
};
|
||||
|
||||
SIZES
|
||||
@@ -346,3 +381,29 @@ pub async fn get_user_picture(
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn size_rules() {
|
||||
assert!(check_size(4000, 4000, 4000 * 4000 * 4).is_ok());
|
||||
assert!(check_size(8000, 2000, 8000 * 2000 * 3).is_ok());
|
||||
for (w, h) in [
|
||||
(0, 10),
|
||||
(10, 0),
|
||||
(8001, 1),
|
||||
(1, 8001),
|
||||
(8000, 2001),
|
||||
(4001, 4000),
|
||||
] {
|
||||
let error = check_size(w, h, 1).unwrap_err();
|
||||
assert_eq!(error.code, ErrorCode::ImageInvalid, "{w} × {h}");
|
||||
}
|
||||
// 3000 × 3000 RGBA16 is 9 MP, under the pixel cap, but 72 MB decoded.
|
||||
let error = check_size(3000, 3000, 3000 * 3000 * 8).unwrap_err();
|
||||
assert_eq!(error.code, ErrorCode::ImageInvalid);
|
||||
assert!(check_size(3000, 3000, MAX_DECODE_ALLOC).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +230,25 @@ async fn e9_unsupported_media_type_image_invalid_and_payload_too_large() {
|
||||
assert_eq!(me.json["user"]["picture_version"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn e9_refuses_more_than_16_megapixels() {
|
||||
// D-114: a few kilobytes of flat PNG may decode to a huge image; the pixel count is capped before decoding.
|
||||
let app = TestApp::household().await;
|
||||
let over = encode(
|
||||
DynamicImage::ImageLuma8(image::GrayImage::new(8000, 2001)),
|
||||
ImageFormat::Png,
|
||||
);
|
||||
assert!(over.len() < 5 * 1024 * 1024);
|
||||
let reply = upload(&app, &admin_token(), Some("image/png"), over).await;
|
||||
assert_error(&reply, 422, "image_invalid");
|
||||
let at_cap = encode(
|
||||
DynamicImage::ImageLuma8(image::GrayImage::new(8000, 2000)),
|
||||
ImageFormat::Png,
|
||||
);
|
||||
let reply = upload(&app, &admin_token(), Some("image/png"), at_cap).await;
|
||||
assert_eq!(reply.status, 200, "{}", reply.text());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn e10_removes_idempotently_and_the_next_upload_continues_the_count() {
|
||||
let app = TestApp::household().await;
|
||||
|
||||
Reference in New Issue
Block a user