diff --git a/Cargo.lock b/Cargo.lock index 9e10593..915e2a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1667,6 +1667,7 @@ dependencies = [ "figcraft-pathops", "figcraft-render", "figcraft-text", + "flate2", "thiserror 2.0.21", "usvg", ] diff --git a/crates/svg/Cargo.toml b/crates/svg/Cargo.toml index a52667e..d248bdf 100644 --- a/crates/svg/Cargo.toml +++ b/crates/svg/Cargo.toml @@ -17,4 +17,5 @@ figcraft-text = { workspace = true } figcraft-pathops = { workspace = true } usvg = { workspace = true } base64 = { workspace = true } +flate2 = { workspace = true } thiserror = { workspace = true } diff --git a/crates/svg/src/import/budget.rs b/crates/svg/src/import/budget.rs new file mode 100644 index 0000000..e7ae3fc --- /dev/null +++ b/crates/svg/src/import/budget.rs @@ -0,0 +1,108 @@ +//! Resource budgets for what an SVG expands into while it is parsed: compressed (SVGZ) input, +//! and SVG documents embedded in `` data URIs (themselves possibly compressed or nested). +//! usvg decompresses with no cap and parses embedded SVGs with no shared depth or byte limit, so +//! both are done here: a bounded gunzip before usvg sees the bytes, and our own data resolver. + +use std::io::Read; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use usvg::{ImageHrefResolver, ImageKind, Options}; + +/// Largest document, once decompressed. +const MAX_DOCUMENT_BYTES: usize = 64 << 20; +/// Largest total of all embedded SVG documents, once decompressed. +const MAX_EMBEDDED_BYTES: usize = 32 << 20; +/// Most SVG documents embedded inside one another. +const MAX_EMBEDDED_DEPTH: usize = 4; + +#[derive(Clone, Copy)] +pub(super) struct Limits { + pub document: usize, + pub embedded: usize, + pub depth: usize, +} + +impl Default for Limits { + fn default() -> Self { + Limits { document: MAX_DOCUMENT_BYTES, embedded: MAX_EMBEDDED_BYTES, depth: MAX_EMBEDDED_DEPTH } + } +} + +fn gzipped(data: &[u8]) -> bool { + data.starts_with(&[0x1f, 0x8b]) +} + +/// `data` as plain text bytes: gunzipped when compressed, never past `limit` bytes. +fn inflate(data: &[u8], limit: usize) -> Result, String> { + if !gzipped(data) { + return if data.len() > limit { Err(format!("SVG larger than {} MiB", limit >> 20)) } else { Ok(data.to_vec()) }; + } + let mut out = Vec::new(); + let cap = u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1); + flate2::read::GzDecoder::new(data).take(cap).read_to_end(&mut out).map_err(|_| "malformed compressed SVG".to_string())?; + if out.len() > limit { + return Err(format!("compressed SVG expands past {} MiB", limit >> 20)); + } + Ok(out) +} + +/// The document `data` (SVG or SVGZ) as plain bytes, within the document budget. +pub(super) fn document(data: &[u8], limits: &Limits) -> Result, String> { + // The caller already holds a plain document; only what compression expands to is bounded. + if gzipped(data) { inflate(data, limits.document) } else { Ok(data.to_vec()) } +} + +/// What the embedded SVGs of one import have used so far. +struct Used { + bytes: AtomicUsize, + depth: AtomicUsize, +} + +fn raster(data: &[u8]) -> bool { + data.starts_with(b"\x89PNG") + || data.starts_with(&[0xff, 0xd8, 0xff]) + || data.starts_with(b"GIF8") + || (data.len() > 12 && data.get(8..12) == Some(b"WEBP")) +} + +/// An embedded SVG document, if the budgets allow it. +fn embedded(used: &Used, limits: &Limits, data: &[u8], opts: &Options) -> Option { + if used.depth.load(Ordering::Relaxed) >= limits.depth { + return None; + } + let left = limits.embedded.saturating_sub(used.bytes.load(Ordering::Relaxed)); + let plain = inflate(data, left).ok()?; + used.bytes.fetch_add(plain.len(), Ordering::Relaxed); + if super::nesting(&plain, super::MAX_NESTING) > super::MAX_NESTING { + return None; + } + used.depth.fetch_add(1, Ordering::Relaxed); + let tree = usvg::Tree::from_data_nested(&plain, opts); + used.depth.fetch_sub(1, Ordering::Relaxed); + tree.ok().map(ImageKind::SVG) +} + +/// Resolve `` data URIs: rasters as they are, SVGs within `limits`; nothing else. +pub(super) fn resolver(limits: Limits) -> ImageHrefResolver<'static> { + let used = Arc::new(Used { bytes: AtomicUsize::new(0), depth: AtomicUsize::new(0) }); + ImageHrefResolver { + resolve_data: Box::new(move |mime, data, opts| match mime { + "image/jpg" | "image/jpeg" => Some(ImageKind::JPEG(data)), + "image/png" => Some(ImageKind::PNG(data)), + "image/gif" => Some(ImageKind::GIF(data)), + "image/webp" => Some(ImageKind::WEBP(data)), + "image/svg+xml" => embedded(&used, &limits, &data, opts), + "text/plain" if raster(&data) => match data.first() { + Some(0x89) => Some(ImageKind::PNG(data)), + Some(0xff) => Some(ImageKind::JPEG(data)), + Some(b'G') => Some(ImageKind::GIF(data)), + _ => Some(ImageKind::WEBP(data)), + }, + "text/plain" => embedded(&used, &limits, &data, opts), + _ => None, + }), + // Linked files are never read. + resolve_string: Box::new(|_, _| None), + } +} diff --git a/crates/svg/src/import/budget_tests.rs b/crates/svg/src/import/budget_tests.rs new file mode 100644 index 0000000..d3dcd2d --- /dev/null +++ b/crates/svg/src/import/budget_tests.rs @@ -0,0 +1,113 @@ +//! Budgets on what SVGZ and embedded SVG documents expand into, with reduced limits so the +//! fixtures stay small. + +use std::io::Write; + +use base64::Engine as _; +use figcraft_doc::Document; + +use super::budget::Limits; +use super::{import_svg, import_within}; + +fn gzip(data: &[u8]) -> Vec { + let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + e.write_all(data).unwrap(); + e.finish().unwrap() +} + +fn svg(body: &str) -> String { + format!(r##"{body}"##) +} + +fn uri(svg: &[u8]) -> String { + format!("data:image/svg+xml;base64,{}", base64::engine::general_purpose::STANDARD.encode(svg)) +} + +/// `levels` SVGs, each embedding the next, the innermost holding one rectangle. +fn nested(levels: usize) -> String { + let mut s = svg(r##""##); + for _ in 1..levels { + s = svg(&format!(r##""##, uri(s.as_bytes()))); + } + s +} + +/// The layers an import makes (None when it is refused). +fn layers(data: &[u8], limits: Limits) -> Result { + let mut d = Document::new("budget"); + let p = d.first_page().unwrap(); + let f = import_within(&mut d, data, p, 0.0, 0.0, "x", limits).map_err(|e| e.0)?.frame; + assert!(d.validate().is_empty(), "{:?}", d.validate()); + Ok(d.subtree(f).len()) +} + +#[test] +fn an_svgz_is_unpacked_and_imported() { + let z = gzip(svg(r##""##).as_bytes()); + let mut d = Document::new("z"); + let p = d.first_page().unwrap(); + assert!(import_svg(&mut d, &z, p, 0.0, 0.0, "z").is_ok()); +} + +#[test] +fn an_svgz_that_expands_past_the_budget_is_refused_early() { + let limits = Limits { document: 4096, ..Limits::default() }; + // Tiny compressed, far past 4 KiB expanded. + let z = gzip(svg(&" ".repeat(1 << 20)).as_bytes()); + assert!(z.len() < 4096, "the bomb is small: {}", z.len()); + let e = layers(&z, limits).unwrap_err(); + assert!(e.contains("expands past"), "{e}"); + // The same document is fine within a bigger budget. + assert!(layers(&z, Limits { document: 2 << 20, ..Limits::default() }).is_ok()); +} + +#[test] +fn a_compressed_document_is_checked_for_nesting_once_unpacked() { + let deep = format!("{}{}", "".repeat(600), "".repeat(600)); + let z = gzip(svg(&deep).as_bytes()); + assert!(layers(&z, Limits::default()).unwrap_err().contains("nested more than")); +} + +#[test] +fn embedded_svgs_nest_up_to_the_depth_budget_and_no_further() { + let data = nested(4).into_bytes(); + let full = layers(&data, Limits::default()).unwrap(); + let shallow = layers(&data, Limits { depth: 1, ..Limits::default() }).unwrap(); + assert!(shallow < full, "depth 1 drops the inner documents: {shallow} < {full}"); + // Far deeper than the budget: still imports, bounded. + let deep = nested(40).into_bytes(); + assert!(layers(&deep, Limits::default()).is_ok()); +} + +#[test] +fn embedded_svgs_share_one_byte_budget() { + let inner = svg(r##""##); + let one = format!(r##""##, uri(inner.as_bytes())); + let data = svg(&one.repeat(8)).into_bytes(); + let all = layers(&data, Limits::default()).unwrap(); + let limits = Limits { embedded: inner.len() * 3, ..Limits::default() }; + let some = layers(&data, limits).unwrap(); + assert!(some < all, "the budget runs out part way: {some} < {all}"); +} + +#[test] +fn an_embedded_compressed_bomb_is_dropped_not_expanded() { + let bomb = gzip(svg(&" ".repeat(1 << 20)).as_bytes()); + let data = svg(&format!( + r##""##, + base64::engine::general_purpose::STANDARD.encode(&bomb) + )) + .into_bytes(); + let limits = Limits { embedded: 4096, ..Limits::default() }; + let with_bomb = layers(&data, limits).unwrap(); + let empty = layers(svg("").as_bytes(), limits).unwrap(); + assert_eq!(with_bomb, empty, "the oversized embedded document left nothing behind"); +} + +#[test] +fn linked_files_and_other_mime_types_stay_unresolved() { + let data = svg( + r##""##, + ); + assert_eq!(layers(data.as_bytes(), Limits::default()).unwrap(), layers(svg("").as_bytes(), Limits::default()).unwrap()); +} diff --git a/crates/svg/src/import/mod.rs b/crates/svg/src/import/mod.rs index 91af5f0..fdae715 100644 --- a/crates/svg/src/import/mod.rs +++ b/crates/svg/src/import/mod.rs @@ -16,11 +16,14 @@ //! What can't be represented (filters other than shadows and blurs, rotated repeating patterns, //! nested clip paths) is left out and reported in the import notes. Linked files are never read: only data URIs. +mod budget; mod fx; mod paint; mod shape; mod text; +#[cfg(test)] +mod budget_tests; #[cfg(test)] mod fx_tests; #[cfg(test)] @@ -592,10 +595,10 @@ pub(crate) fn nesting(data: &[u8], limit: usize) -> usize { max } -fn options(data: &[u8]) -> usvg::Options<'static> { - let mut o = usvg::Options::default(); - // Only data URIs: an opened or pasted SVG never pulls other files off the disk. - o.image_href_resolver.resolve_string = Box::new(|_, _| None); +fn options(data: &[u8], limits: budget::Limits) -> usvg::Options<'static> { + // Only data URIs, within budgets: an opened or pasted SVG never pulls other files off the disk + // and never expands without limit. + let mut o = usvg::Options { image_href_resolver: budget::resolver(limits), ..Default::default() }; if data.windows(4).any(|w| w == b"text") { text::prepare(&mut o); } @@ -634,10 +637,24 @@ pub fn import_svg(doc: &mut Document, data: &[u8], parent: NodeId, x: f64, y: f6 /// [`import_svg`], with notes on what couldn't be represented. pub fn import_svg_with_notes(doc: &mut Document, data: &[u8], parent: NodeId, x: f64, y: f64, name: &str) -> Result { - if nesting(data, MAX_NESTING) > MAX_NESTING { + import_within(doc, data, parent, x, y, name, budget::Limits::default()) +} + +fn import_within( + doc: &mut Document, + data: &[u8], + parent: NodeId, + x: f64, + y: f64, + name: &str, + limits: budget::Limits, +) -> Result { + // SVGZ is unpacked here, within a budget, so every check below sees the real document. + let data = budget::document(data, &limits).map_err(ImportError)?; + if nesting(&data, MAX_NESTING) > MAX_NESTING { return Err(ImportError(format!("elements nested more than {MAX_NESTING} deep"))); } - let tree = usvg::Tree::from_data(data, &options(data)).map_err(|e| ImportError(e.to_string()))?; + let tree = usvg::Tree::from_data(&data, &options(&data, limits)).map_err(|e| ImportError(e.to_string()))?; let size = tree.size(); let frame = doc.create(parent, NodeKind::Frame, name, x, y, f64::from(size.width()), f64::from(size.height())).map_err(|e| ImportError(e.to_string()))?;