From 3675a2cdc65a7187467dc9a13e1b9785e80a106a Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Tue, 10 Feb 2026 17:14:16 +0000 Subject: [PATCH] fix: add decompression bomb protection and MIME type validation Set image decoder limits (10000x10000 max dimensions, 256MB max alloc) to prevent crafted images from causing OOM via decompression bombs. Validate data URL MIME types against an allowlist (jpeg/png/webp) before decoding, rejecting non-image content types early. --- src/infrastructure/image_processing.rs | 44 +++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/src/infrastructure/image_processing.rs b/src/infrastructure/image_processing.rs index 2214498..d03be79 100644 --- a/src/infrastructure/image_processing.rs +++ b/src/infrastructure/image_processing.rs @@ -9,6 +9,15 @@ const MAX_FULL_SIZE: u32 = 1200; /// Maximum dimension (width or height) for the thumbnail. const MAX_THUMBNAIL_SIZE: u32 = 200; +/// Maximum allowed input dimension (width or height) to prevent decompression bombs. +const MAX_INPUT_DIMENSION: u32 = 10_000; + +/// Maximum memory the decoder may allocate (256 MB). +const MAX_DECODER_ALLOC: u64 = 256 * 1024 * 1024; + +/// Allowed MIME types in data URLs. +const ALLOWED_MIMES: &[&str] = &["image/jpeg", "image/png", "image/webp"]; + /// JPEG quality for the full-size image (0-100). const JPEG_QUALITY_FULL: u8 = 85; @@ -33,11 +42,17 @@ pub fn process_data_url(data_url: &str) -> anyhow::Result { /// Process raw image bytes (JPEG/PNG/WebP) into resized full + thumbnail JPEGs. pub fn process_image_bytes(raw_bytes: &[u8]) -> anyhow::Result { - let img = ImageReader::new(Cursor::new(raw_bytes)) + let mut reader = ImageReader::new(Cursor::new(raw_bytes)) .with_guessed_format() - .context("failed to guess image format")? - .decode() - .context("failed to decode image")?; + .context("failed to guess image format")?; + + let mut limits = image::Limits::default(); + limits.max_image_width = Some(MAX_INPUT_DIMENSION); + limits.max_image_height = Some(MAX_INPUT_DIMENSION); + limits.max_alloc = Some(MAX_DECODER_ALLOC); + reader.limits(limits); + + let img = reader.decode().context("failed to decode image")?; let full = img.resize( MAX_FULL_SIZE, @@ -67,10 +82,14 @@ fn decode_data_url(data_url: &str) -> anyhow::Result> { bail!("invalid data URL: missing data: prefix"); }; - let Some((_mime, encoded)) = rest.split_once(',') else { + let Some((mime, encoded)) = rest.split_once(',') else { bail!("invalid data URL: missing comma separator"); }; + if !ALLOWED_MIMES.iter().any(|m| mime.contains(m)) { + bail!("unsupported image type: {mime}"); + } + base64::engine::general_purpose::STANDARD .decode(encoded.trim()) .context("failed to decode base64 image data") @@ -109,4 +128,19 @@ mod tests { let result = decode_data_url("data:image/png;base64"); assert!(result.is_err()); } + + #[test] + fn decode_data_url_rejects_non_image_mime() { + let data = base64::engine::general_purpose::STANDARD.encode(b"hello"); + let url = format!("data:text/html;base64,{data}"); + let result = decode_data_url(&url); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("unsupported image type"), + "should reject non-image MIME types" + ); + } }