From baa580681216e9ee9b866591398aa6f165fbba5e Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sun, 18 Oct 2026 22:42:35 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B --- .../crates/kvitt-server/src/api/pictures.rs | 95 +++++++++++++++---- .../kvitt-server/tests/core_api/pictures.rs | 19 ++++ 2 files changed, 97 insertions(+), 17 deletions(-) diff --git a/server/crates/kvitt-server/src/api/pictures.rs b/server/crates/kvitt-server/src/api/pictures.rs index 5177246..135bcc2 100644 --- a/server/crates/kvitt-server/src/api/pictures.rs +++ b/server/crates/kvitt-server/src/api/pictures.rs @@ -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//-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 { } } -/// 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>, 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>, 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>, 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()); + } +} diff --git a/server/crates/kvitt-server/tests/core_api/pictures.rs b/server/crates/kvitt-server/tests/core_api/pictures.rs index 5ed6dcc..ef27cbb 100644 --- a/server/crates/kvitt-server/tests/core_api/pictures.rs +++ b/server/crates/kvitt-server/tests/core_api/pictures.rs @@ -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;