mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
A tiny file declaring a huge image reserved the full output buffer before any data was checked. Add Limits::check_plausible (compression-ratio guard) and a fallible alloc_zeroed, use them in PNG and WebP, and lower the 32-bit default max_alloc to 1 GiB. Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
b564f2ca9c
commit
21f1012ce2
4 files changed
+62
-6
No files matched your search
@@ -7,7 +7,7 @@ use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::fidelity::Plan;
|
||||
use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType};
|
||||
use crate::options::{EncodeOptions, Limits, PngCompression};
|
||||
use crate::options::{EncodeOptions, Limits, PngCompression, alloc_zeroed};
|
||||
|
||||
const F: Format = Format::Png;
|
||||
pub(crate) const XMP_KEYWORD: &str = "XML:com.adobe.xmp";
|
||||
@@ -29,6 +29,10 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
let (w, h) = info.size();
|
||||
// Worst case output: 4 channels x 16 bit.
|
||||
limits.check_bytes(w, h, 8)?;
|
||||
// zlib never beats 1032:1; refuse a file too small for the declared image before the
|
||||
// output buffer exists. Measured on the stored samples (before palette/bit expansion).
|
||||
let stored = u64::from(w).saturating_mul(u64::from(h)).saturating_mul(info.bits_per_pixel() as u64).div_ceil(8);
|
||||
Limits::check_plausible(stored, bytes.len(), 1100)?;
|
||||
}
|
||||
let mut reader = decoder.read_info().map_err(map_png_err)?;
|
||||
// APNG: the IDAT image is the first frame, or an extra default image when no fcTL precedes it.
|
||||
@@ -47,7 +51,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
d => return Err(err(format!("unexpected output bit depth {d:?}"))),
|
||||
};
|
||||
let size = reader.output_buffer_size().ok_or_else(|| err("image too large"))?;
|
||||
let mut buf = vec![0u8; size];
|
||||
let mut buf = alloc_zeroed::<u8>(size)?;
|
||||
let out = reader.next_frame(&mut buf).map_err(map_png_err)?;
|
||||
buf.truncate(out.buffer_size());
|
||||
// Collect ancillary chunks after IDAT. Tolerate malformed tails once pixels
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::fidelity::Plan;
|
||||
use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType};
|
||||
use crate::options::{EncodeOptions, Limits};
|
||||
use crate::options::{EncodeOptions, Limits, alloc_zeroed};
|
||||
|
||||
const F: Format = Format::WebP;
|
||||
|
||||
@@ -23,8 +23,11 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
let (w, h) = dec.dimensions();
|
||||
let layout = if dec.has_alpha() { ChannelLayout::Rgba } else { ChannelLayout::Rgb };
|
||||
limits.check(w, h, layout, SampleType::U8)?;
|
||||
// VP8L back-references and VP8 skipped macroblocks compress flat areas extremely well, so the
|
||||
// ratio is generous; it still stops a header-only file from asking for a gigabyte.
|
||||
Limits::check_plausible(u64::from(w) * u64::from(h) * layout.channels() as u64, bytes.len(), 1 << 16)?;
|
||||
let size = dec.output_buffer_size().ok_or_else(|| err("image too large"))?;
|
||||
let mut buf = vec![0u8; size];
|
||||
let mut buf = alloc_zeroed::<u8>(size)?;
|
||||
dec.read_image(&mut buf).map_err(err)?;
|
||||
let icc = dec.icc_profile().ok().flatten();
|
||||
let exif = dec.exif_metadata().ok().flatten();
|
||||
|
||||
@@ -21,8 +21,10 @@ pub struct Limits {
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
// 2^30 pixels (e.g. 32768²) and, on 64-bit targets, 8 GiB: enough for the large
|
||||
// documents Photoshop users open (a 20000² RGBA 16-bit image is 3.2 GB).
|
||||
let max_alloc = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 2 << 30 };
|
||||
// documents Photoshop users open (a 20000² RGBA 16-bit image is 3.2 GB). On 32-bit
|
||||
// targets (wasm32 has a 4 GiB address space shared with the document, history and
|
||||
// the decoder's own buffers) 1 GiB is the most one buffer can safely ask for.
|
||||
let max_alloc = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 1 << 30 };
|
||||
Limits { max_width: 1 << 18, max_height: 1 << 18, max_pixels: 1 << 30, max_alloc }
|
||||
}
|
||||
}
|
||||
@@ -60,6 +62,19 @@ impl Limits {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decompression-bomb guard for formats that compress: refuse a file too small to hold
|
||||
/// `declared_bytes` of (stored, pre-expansion) image data, given that the compression
|
||||
/// never beats `max_ratio`:1. Called before the output buffer is allocated, so a
|
||||
/// header-only hostile file cannot make the decoder reserve gigabytes. A fixed 1 MiB of
|
||||
/// slack keeps tiny images and headers with large metadata unaffected.
|
||||
pub(crate) fn check_plausible(declared_bytes: u64, file_len: usize, max_ratio: u64) -> Result<(), CodecError> {
|
||||
let capacity = (file_len as u64).saturating_mul(max_ratio).saturating_add(1 << 20);
|
||||
if declared_bytes > capacity {
|
||||
return Err(CodecError::LimitExceeded(format!("a {file_len} byte file cannot hold {declared_bytes} bytes of image data")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn alloc_usize(&self) -> usize {
|
||||
usize::try_from(self.max_alloc).unwrap_or(usize::MAX)
|
||||
}
|
||||
@@ -149,3 +164,13 @@ impl Default for EncodeOptions {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A zero-filled buffer of `len` elements, allocated fallibly: an allocation failure becomes
|
||||
/// [`CodecError::LimitExceeded`] instead of aborting the process (which `catch_unwind` cannot
|
||||
/// stop).
|
||||
pub(crate) fn alloc_zeroed<T: Copy + Default>(len: usize) -> Result<Vec<T>, CodecError> {
|
||||
let mut v = Vec::new();
|
||||
v.try_reserve_exact(len).map_err(|_| CodecError::LimitExceeded(format!("cannot allocate {len} elements for the image")))?;
|
||||
v.resize(len, T::default());
|
||||
Ok(v)
|
||||
}
|
||||
@@ -93,6 +93,30 @@ fn png_bomb_header_rejected_before_alloc() {
|
||||
assert!(is_limit(decode(&b)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_header_at_the_default_limit_needs_a_plausible_file_size() {
|
||||
// 32768 x 32768 x RGBA16 is exactly the default max_alloc: it passes the dimension limits,
|
||||
// but a ~100 byte file cannot hold it (zlib tops out near 1032:1), so it is refused before
|
||||
// the 8 GiB output buffer is allocated.
|
||||
let mut ihdr = Vec::new();
|
||||
ihdr.extend_from_slice(&32768u32.to_be_bytes());
|
||||
ihdr.extend_from_slice(&32768u32.to_be_bytes());
|
||||
ihdr.extend_from_slice(&[16, 6, 0, 0, 0]);
|
||||
let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
b.extend(png_chunk(b"IHDR", &ihdr));
|
||||
b.extend(png_chunk(b"IDAT", &[0x78, 0x9C, 0x03, 0x00, 0x00, 0x00, 0x00, 0x01]));
|
||||
b.extend(png_chunk(b"IEND", &[]));
|
||||
assert!(is_limit(decode(&b)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_highly_compressible_image_still_decodes() {
|
||||
let img = Image::from_u8(3000, 3000, ChannelLayout::Rgba, vec![255u8; 3000 * 3000 * 4]).unwrap();
|
||||
let b = encode(&img, Format::Png, &EncodeOptions::default()).unwrap();
|
||||
assert!(b.len() < 100_000, "{}", b.len());
|
||||
assert_eq!(decode(&b).unwrap().width(), 3000);
|
||||
}
|
||||
|
||||
const PNG_TEXT_BUDGET: usize = 64 << 10;
|
||||
|
||||
fn png_text_decode(bytes: &[u8], max_alloc: usize) -> Result<Image, CodecError> {
|
||||
|
||||
Reference in new issue
Block a user