Merge pull request #8504 from dylanmtaylor/security/svg-embedded-image-admission

security: admit embedded SVG raster images by header size
This commit is contained in:
Dylan M. Taylor
2026-10-22 19:42:44 -04:00
committed by GitHub
2 changed files with 116 additions and 1 deletions
+79 -1
View File
@@ -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<Option<(u32, u32)>> {
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<String> {
/// `~/.ssh/...`) into the document. Embedded SVG images get no `<image>` resolution at all
/// (`usvg` already blanks it for sub-SVGs).
fn options(fontdb: Arc<usvg::fontdb::Database>) -> 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());
}
}
+37
View File
@@ -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!(
"<svg {NS} width=\"20\" height=\"20\"><image id=\"photo\" x=\"0\" y=\"0\" width=\"20\" height=\"20\" href=\"{}\"/></svg>",
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!(