mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
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:
+79
-1
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user