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).
|
//! 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
|
//! An upload is JPEG, PNG or WebP (by `Content-Type`), at most 5 MiB (the body limit in `router.rs`), 8000 × 8000
|
||||||
//! pixels. The server applies the EXIF orientation, centre-crops to a square, flattens any transparency onto white,
|
//! 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
|
//! 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
|
//! `pictures/<user_id>/<picture_version>-512.jpg` and `-128.jpg`; older versions are deleted after a successful
|
||||||
//! upload (SPEC §10.4).
|
//! upload (SPEC §10.4).
|
||||||
@@ -21,7 +23,7 @@ use axum::response::{IntoResponse, Response};
|
|||||||
use image::codecs::jpeg::JpegEncoder;
|
use image::codecs::jpeg::JpegEncoder;
|
||||||
use image::imageops::FilterType;
|
use image::imageops::FilterType;
|
||||||
use image::metadata::Orientation;
|
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 uuid::Uuid;
|
||||||
|
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
@@ -37,6 +39,11 @@ use crate::wire::{IntSchema, JpegBytes, PictureSizeSchema, PngBytes, User, UuidS
|
|||||||
|
|
||||||
/// Largest accepted width and height (AC-4.4).
|
/// Largest accepted width and height (AC-4.4).
|
||||||
pub const MAX_DIMENSION: u32 = 8000;
|
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).
|
/// JPEG quality of the stored pictures (AC-4.4).
|
||||||
pub const JPEG_QUALITY: u8 = 85;
|
pub const JPEG_QUALITY: u8 = 85;
|
||||||
/// The stored sizes (AC-4.4); E-11 serves 512 by default.
|
/// 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.
|
/// The decoder limits of an upload (D-114).
|
||||||
pub fn process(bytes: &[u8], format: ImageFormat) -> Result<Vec<Vec<u8>>, AppError> {
|
fn decode_limits() -> Limits {
|
||||||
let reader = ImageReader::with_format(Cursor::new(bytes), format);
|
let mut limits = Limits::default();
|
||||||
let mut decoder = reader.into_decoder().map_err(image_invalid)?;
|
limits.max_image_width = Some(MAX_DIMENSION);
|
||||||
let (width, height) = decoder.dimensions();
|
limits.max_image_height = Some(MAX_DIMENSION);
|
||||||
if width == 0 || height == 0 || width > MAX_DIMENSION || height > 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")));
|
return Err(image_invalid(format!("{width} × {height} pixels")));
|
||||||
}
|
}
|
||||||
let orientation = decoder.orientation().unwrap_or(Orientation::NoTransforms);
|
if total_bytes > MAX_DECODE_ALLOC {
|
||||||
let mut image = DynamicImage::from_decoder(decoder).map_err(image_invalid)?;
|
return Err(image_invalid(format!("{total_bytes} bytes decoded")));
|
||||||
image.apply_orientation(orientation);
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
let (w, h) = (image.width(), image.height());
|
/// Decodes, orients, crops and re-encodes an upload. Returns the JPEG bytes for each of [`SIZES`], in that order.
|
||||||
let side = w.min(h);
|
pub fn process(bytes: &[u8], format: ImageFormat) -> Result<Vec<Vec<u8>>, AppError> {
|
||||||
let square = image.crop_imm((w - side) / 2, (h - side) / 2, side, side);
|
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 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 flat = RgbImage::from_fn(side, side, |x, y| {
|
||||||
let p = rgba.get_pixel(x, y).0;
|
let p = rgba.get_pixel(x, y).0;
|
||||||
let a = u32::from(p[3]);
|
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)
|
DynamicImage::ImageRgb8(flat)
|
||||||
} else {
|
} else {
|
||||||
DynamicImage::ImageRgb8(square.to_rgb8())
|
DynamicImage::ImageRgb8(square.into_rgb8())
|
||||||
};
|
};
|
||||||
|
|
||||||
SIZES
|
SIZES
|
||||||
@@ -346,3 +381,29 @@ pub async fn get_user_picture(
|
|||||||
);
|
);
|
||||||
Ok(response)
|
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);
|
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]
|
#[tokio::test]
|
||||||
async fn e10_removes_idempotently_and_the_next_upload_continues_the_count() {
|
async fn e10_removes_idempotently_and_the_next_upload_continues_the_count() {
|
||||||
let app = TestApp::household().await;
|
let app = TestApp::household().await;
|
||||||
|
|||||||
Reference in New Issue
Block a user