M1: SVG import budgets for SVGZ and embedded SVGs (#190) (#241)

usvg unpacks SVGZ with no limit and parses SVGs embedded in `<image>` data
URIs with no shared depth or byte budget, and the nesting check ran on the
compressed bytes. FigCraft now unpacks SVGZ itself within 64 MiB, checks the
real document, and resolves embedded SVGs with its own data resolver: one
32 MiB budget of unpacked bytes and at most 4 levels, an embedded document
past a budget is left out. Rasters still resolve; linked files stay unread.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: bflatastic <bombayapirat@gmail.com>
This commit is contained in:
Nathan Fontenele 2026-10-14 00:32:30 -03:00 committed by GitHub
parent 24c5617230
commit ef2c6cb30e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 246 additions and 6 deletions

1
Cargo.lock generated
View File

@ -1667,6 +1667,7 @@ dependencies = [
"figcraft-pathops",
"figcraft-render",
"figcraft-text",
"flate2",
"thiserror 2.0.21",
"usvg",
]

View File

@ -17,4 +17,5 @@ figcraft-text = { workspace = true }
figcraft-pathops = { workspace = true }
usvg = { workspace = true }
base64 = { workspace = true }
flate2 = { workspace = true }
thiserror = { workspace = true }

View File

@ -0,0 +1,108 @@
//! Resource budgets for what an SVG expands into while it is parsed: compressed (SVGZ) input,
//! and SVG documents embedded in `<image>` 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<Vec<u8>, 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<Vec<u8>, 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<ImageKind> {
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 `<image>` 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),
}
}

View File

@ -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<u8> {
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##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="100">{body}</svg>"##)
}
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##"<rect width="10" height="10" fill="red"/>"##);
for _ in 1..levels {
s = svg(&format!(r##"<image width="100" height="100" xlink:href="{}"/>"##, uri(s.as_bytes())));
}
s
}
/// The layers an import makes (None when it is refused).
fn layers(data: &[u8], limits: Limits) -> Result<usize, String> {
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##"<rect width="10" height="10"/>"##).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!("{}{}", "<g>".repeat(600), "</g>".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##"<rect width="10" height="10"/>"##);
let one = format!(r##"<image width="10" height="10" xlink:href="{}"/>"##, 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##"<image width="10" height="10" xlink:href="data:image/svg+xml;base64,{}"/>"##,
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##"<image width="10" height="10" xlink:href="/etc/passwd"/><image width="10" height="10" xlink:href="data:application/x-foo;base64,AAAA"/>"##,
);
assert_eq!(layers(data.as_bytes(), Limits::default()).unwrap(), layers(svg("").as_bytes(), Limits::default()).unwrap());
}

View File

@ -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<Imported, ImportError> {
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<Imported, ImportError> {
// 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()))?;