diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index ac1bd27ffa..d656f58103 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -28,6 +28,48 @@ use crate::{ImportResult, IoError}; pub const MAX_SIDE: u32 = 16_384; /// A rasterised fallback layer or a smart-object render never exceeds this many pixels. const MAX_RASTER_PIXELS: u64 = 24_000_000; +/// Largest decoded byte size (RGBA8) accepted for an embedded raster image before resvg decodes +/// it. resvg/tiny-skia allocate the full intrinsic image before any destination-size scaling, +/// so a highly compressed PNG or WebP could otherwise trigger gigabyte-scale allocation and +/// decompression that the destination pixmap's size check never sees. +const MAX_EMBEDDED_IMAGE_BYTES: u64 = MAX_RASTER_PIXELS * 4; + +/// The IHDR width/height of a PNG, read directly from its header (no decoding): the signature +/// (8 bytes), the IHDR chunk's length and type (8 bytes), then big-endian width and height. +fn png_dimensions(data: &[u8]) -> Option<(u32, u32)> { + let sig = data.get(0..8)?; + if sig != [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A] { + return None; + } + if data.get(12..16)? != b"IHDR" { + return None; + } + let w = u32::from_be_bytes(data.get(16..20)?.try_into().ok()?); + let h = u32::from_be_bytes(data.get(20..24)?.try_into().ok()?); + Some((w, h)) +} + +/// Intrinsic dimensions of a raster image resvg can embed, from its header alone, for formats +/// where decoding allocates the full image before any destination scaling (PNG, WebP). +/// Returns `Some(Some((w, h)))` when the format is PNG/WebP and its header parses cleanly, +/// `Some(None)` when the format is PNG/WebP (by MIME or magic bytes under `"text/plain"`) +/// and its header is malformed, or `None` for other formats. +fn embedded_image_dimensions(mime: &str, data: &[u8]) -> Option> { + match mime { + "image/png" => Some(png_dimensions(data)), + "image/webp" => Some(photocraft_codecs::probe_size(data).ok()), + "text/plain" => { + if data.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) { + Some(png_dimensions(data)) + } else if data.get(0..4) == Some(b"RIFF") && data.get(8..12) == Some(b"WEBP") { + Some(photocraft_codecs::probe_size(data).ok()) + } else { + None + } + } + _ => None, + } +} /// Aggregate memory for retained fallback surfaces in the document's pixel format. const MAX_PIXEL_BYTES: u64 = 1 << 30; /// Estimated peak during raster conversion (retained surfaces + RGBA8 pixmap + f32 staging + tiles). @@ -139,9 +181,24 @@ fn system_generic_family(_generic: &str) -> Option { /// `~/.ssh/...`) into the document. Embedded SVG images get no `` resolution at all /// (`usvg` already blanks it for sub-SVGs). fn options(fontdb: Arc) -> usvg::Options<'static> { + let default_resolver = usvg::ImageHrefResolver::default_data_resolver(); + let resolve_data: usvg::ImageHrefDataResolverFn<'static> = Box::new(move |mime, data, opts| { + // Reject embedded images whose decoded RGBA storage would exceed the budget before + // resvg (which allocates the full intrinsic image before any destination scaling) ever + // sees them. Formats without a cheap header probe fall through unchanged; their decode + // path is bounded elsewhere or accepted as-is, matching the previous behavior. + if let Some(probed) = embedded_image_dimensions(mime, &data) { + let (w, h) = probed?; + let decoded = u64::from(w).saturating_mul(u64::from(h)).saturating_mul(4); + if decoded > MAX_EMBEDDED_IMAGE_BYTES { + return None; + } + } + default_resolver(mime, data, opts) + }); usvg::Options { resources_dir: None, - image_href_resolver: usvg::ImageHrefResolver { resolve_data: usvg::ImageHrefResolver::default_data_resolver(), resolve_string: Box::new(|_, _| None) }, + image_href_resolver: usvg::ImageHrefResolver { resolve_data, resolve_string: Box::new(|_, _| None) }, fontdb, ..usvg::Options::default() } @@ -1058,4 +1115,25 @@ mod tests { assert_eq!((cv.size.width, cv.size.height), (MAX_SIDE, MAX_SIDE / 2)); assert_eq!(w.len(), 1); } + + #[test] + fn resolve_data_rejects_oversized_and_malformed_embedded_png_and_webp() { + let opt = options(Arc::new(usvg::fontdb::Database::new())); + let resolve = &opt.image_href_resolver.resolve_data; + let img = photocraft_codecs::Image::from_u8(2, 2, photocraft_codecs::ChannelLayout::Rgb, vec![255; 12]).unwrap(); + let valid_png = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + assert!(resolve("image/png", Arc::new(valid_png.clone()), &opt).is_some()); + assert!(resolve("text/plain", Arc::new(valid_png.clone()), &opt).is_some()); + + let mut huge_png = valid_png; + huge_png[16..20].copy_from_slice(&40_000u32.to_be_bytes()); + huge_png[20..24].copy_from_slice(&40_000u32.to_be_bytes()); + assert!(resolve("image/png", Arc::new(huge_png.clone()), &opt).is_none(), "oversized PNG rejected before decode"); + assert!(resolve("text/plain", Arc::new(huge_png), &opt).is_none(), "oversized mime-less PNG rejected before decode"); + + // Malformed WebP header under image/webp or text/plain must be rejected at the probe step. + let bad_webp = b"RIFF\x10\x00\x00\x00WEBPVP8 ".to_vec(); + assert!(resolve("image/webp", Arc::new(bad_webp.clone()), &opt).is_none()); + assert!(resolve("text/plain", Arc::new(bad_webp), &opt).is_none()); + } } diff --git a/crates/io/tests/it/svg.rs b/crates/io/tests/it/svg.rs index d1baf418fa..517190bcac 100644 --- a/crates/io/tests/it/svg.rs +++ b/crates/io/tests/it/svg.rs @@ -190,6 +190,31 @@ fn png_data_url() -> String { out } +/// A small valid PNG whose IHDR claims `w`x`h`, far larger than its actual (tiny) image data. +/// The header alone must be enough to reject it before any decode is attempted. +fn png_data_url_with_claimed_size(w: u32, h: u32) -> String { + let img = photocraft_codecs::Image::from_u8(2, 2, photocraft_codecs::ChannelLayout::Rgb, vec![255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255]).unwrap(); + let mut bytes = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + // IHDR starts right after the 8-byte signature and 8-byte chunk length/type: width at 16, + // height at 20 (big-endian u32 each). + bytes[16..20].copy_from_slice(&w.to_be_bytes()); + bytes[20..24].copy_from_slice(&h.to_be_bytes()); + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::from("data:image/png;base64,"); + for chunk in bytes.chunks(3) { + let n = chunk.len(); + let v = (u32::from(chunk[0]) << 16) | (u32::from(*chunk.get(1).unwrap_or(&0)) << 8) | u32::from(*chunk.get(2).unwrap_or(&0)); + for i in 0..4 { + if i <= n { + out.push(T[((v >> (18 - 6 * i)) & 63) as usize] as char); + } else { + out.push('='); + } + } + } + out +} + #[test] fn embedded_raster_images_become_pixel_layers() { let r = open(&format!( @@ -204,6 +229,18 @@ fn embedded_raster_images_become_pixel_layers() { assert!(close(px(d, 16, 16), [1.0, 1.0, 1.0, 1.0]), "{:?}", px(d, 16, 16)); } +#[test] +fn embedded_png_with_a_huge_claimed_size_is_not_decoded() { + // The IHDR claims 40000x40000 (6.4 GB RGBA decoded) but the display size is tiny; the + // header-only check must reject it before resvg ever decodes the actual (2x2) image data. + let r = open(&format!( + "", + png_data_url_with_claimed_size(40_000, 40_000) + )); + // Rejected at the resolver: no image layer is produced (resvg treats it as a missing image). + assert!(r.document.layers.is_empty() || !matches!(r.document.layers[0].content, LayerContent::Raster(_)), "{:?}", r.document.layers); +} + #[test] fn text_becomes_outline_shapes_or_warns_without_a_font() { let r = open(&format!(