diff --git a/vendor/README.craft-reader.md b/vendor/README.craft-reader.md index e7ab08cf..a056e9e6 100644 --- a/vendor/README.craft-reader.md +++ b/vendor/README.craft-reader.md @@ -42,6 +42,20 @@ referred-to-number bytes are consumed, so a crafted header cannot turn `Vec::with_capacity` into a multi-gigabyte reservation that aborts on allocation failure. +Hostile-input bounds (CodeRabbit findings): + +- An xref-stream subsection must be backed by its decoded bytes: all-zero `/W` widths + are refused, and a declared count longer than the data is refused before any entry is + inserted, so a tiny stream cannot declare billions of entries. +- Each object stream's `/N` header pairs are parsed once per document and cached beside its + decoded data, instead of on every compressed-object lookup. +- An embedded JBIG2 page over 2^28 pixels is refused before the output buffer is allocated, + and that buffer is reserved fallibly. A `JBIG2Globals` stream with an image filter is + ignored, so globals cannot recurse through further globals. +- At most 4096 graphics states, holding at most 64 MiB together, are saved; a `q` past + either limit is counted but not copied, and its `Q` still removes the clips added since. + A dash array over 256 entries strokes solid. + This remains the patched 0.7 backend. It retains the existing Bradford Lab conversion, floating component precision and codec versions, rather than claiming full 0.8 equivalence. The independent color oracles, backend tests and facade contracts cover diff --git a/vendor/hayro-interpret/SOURCE.json b/vendor/hayro-interpret/SOURCE.json index 27811044..2ef84143 100644 --- a/vendor/hayro-interpret/SOURCE.json +++ b/vendor/hayro-interpret/SOURCE.json @@ -188,7 +188,7 @@ "path": "src/context.rs", "source_sha256": "e6898005282196c77bf44357237c79f8c839e74f666e8157756230fd9e2f93ed", "source_git_blob": "3e83229c5f58cfbbefd15bf3b5e7b71380291c01", - "sha256": "86e838ca19dfc47f5bb0d7a2ffff80709f2fd51bc6b62d65151841bcba212e08" + "sha256": "c6f3ca9d12a53042a28dbb8088ee97ba8b2fc6b03ec35867b58fca99dd9b9807" }, { "path": "src/convert.rs", @@ -362,7 +362,7 @@ "path": "src/interpret/mod.rs", "source_sha256": "2b286df0c34b8194e1bdd1b63c1e08354d24f8389958fd782f42e2cabf364673", "source_git_blob": "931b3388cef188cee0a5a1d495b69a6ff04f5e51", - "sha256": "6ae30906c8e6d0e869e0fac8ece4d97e0ac08d8abd7042c0242a069d4e609351" + "sha256": "72650303e920e855cf4c78c82bee2c4b72a4f251c58e16d27bd5ae3860c9983b" }, { "path": "src/interpret/path.rs", @@ -374,7 +374,7 @@ "path": "src/interpret/state.rs", "source_sha256": "dce778291b13f6df83c8c13aee22f60edd033ec9f7d89309ef148d46d3ba9814", "source_git_blob": "95b773fb5cc434ab8db362d00e4e5180a8b740c9", - "sha256": "dce778291b13f6df83c8c13aee22f60edd033ec9f7d89309ef148d46d3ba9814" + "sha256": "a0d726f48645374f5cbf4ecb10906c13cabc747e3de878b9140c73b6d1303819" }, { "path": "src/interpret/text.rs", @@ -422,7 +422,7 @@ "path": "src/types.rs", "source_sha256": "0e66569b8a7dc243b0a384cf1d3615ca62bf660b5324236f2594609bdfddfd46", "source_git_blob": "64d2b849bdd3fdcd4ebfd76845edcef77b625fbd", - "sha256": "0e66569b8a7dc243b0a384cf1d3615ca62bf660b5324236f2594609bdfddfd46" + "sha256": "542fafbfcf8a472466377cacd580eda2fc2d40b834a8d5ecd1ad35d75445f532" }, { "path": "src/util.rs", diff --git a/vendor/hayro-interpret/src/context.rs b/vendor/hayro-interpret/src/context.rs index 9953b419..326aa201 100644 --- a/vendor/hayro-interpret/src/context.rs +++ b/vendor/hayro-interpret/src/context.rs @@ -166,6 +166,23 @@ pub struct Context<'a> { pub(crate) xref: &'a XRef, pub(crate) ocg_state: OcgState, nesting_depth: u32, + /// PdfCraft patch: estimated bytes held by saved states, and for each `q` not saved because + /// a limit was reached, the clip count to return to at its `Q`. + saved_bytes: usize, + skipped_saves: Vec, +} + +/// PdfCraft patch: most graphics states saved at once. Every `q` copies the current state, dash +/// array and clip list included, so without a limit short operators multiply one large state. +const MAX_SAVED_STATES: usize = 4096; +/// PdfCraft patch: most bytes all saved states may hold together. +const MAX_SAVED_STATE_BYTES: usize = 64 << 20; + +/// Roughly what a saved copy of `state` holds. +fn state_bytes(state: &State<'_>) -> usize { + size_of::>() + + state.graphics_state.stroke_props.dash_array.len() * size_of::() + + state.clips.len() * size_of::() } impl<'a> Context<'a> { @@ -220,15 +237,33 @@ impl<'a> Context<'a> { interpreter_cache: cache.clone(), ocg_state, nesting_depth, + saved_bytes: 0, + skipped_saves: Vec::new(), } } pub(crate) fn save_state(&mut self) { - let Some(cur) = self.states.last().cloned() else { + let Some(cur) = self.states.last() else { warn!("attempted to save state without existing state"); return; }; + // PdfCraft patch: past either limit the `q` is recorded but nothing is copied. Its `Q` + // still removes the clips added since; other changes made in between are kept. + let bytes = state_bytes(cur); + if !self.skipped_saves.is_empty() + || self.states.len() >= MAX_SAVED_STATES + || self.saved_bytes.saturating_add(bytes) > MAX_SAVED_STATE_BYTES + { + if self.skipped_saves.is_empty() { + warn!("too many saved graphics states; further saves are not kept"); + } + self.skipped_saves.push(cur.clips.len()); + return; + } + + let cur = cur.clone(); + self.saved_bytes += bytes; self.states.push(cur); } @@ -305,6 +340,14 @@ impl<'a> Context<'a> { } pub(crate) fn restore_state(&mut self, device: &mut impl Device<'a>) { + // PdfCraft patch: a save that was not kept is the most recent one. + if let Some(target_clips) = self.skipped_saves.pop() { + while self.get().clips.len() > target_clips { + self.pop_clip_path(device); + } + return; + } + let Some(target_clips) = self .states .get(self.states.len().saturating_sub(2)) @@ -321,6 +364,10 @@ impl<'a> Context<'a> { // The first state should never be popped. if self.states.len() > 1 { self.states.pop(); + // PdfCraft patch: the state now on top is the copy that was charged when saved. + if let Some(top) = self.states.last() { + self.saved_bytes = self.saved_bytes.saturating_sub(state_bytes(top)); + } } device.set_soft_mask( @@ -392,7 +439,8 @@ impl<'a> Context<'a> { } pub(crate) fn num_states(&self) -> usize { - self.states.len() + // PdfCraft patch: saves not kept still count, so callers restore back past them. + self.states.len() + self.skipped_saves.len() } pub(crate) fn nesting_depth(&self) -> u32 { @@ -484,3 +532,68 @@ pub(crate) fn path_as_rect(path: &BezPath) -> Option { None } } + +#[cfg(test)] +mod saved_state_tests { + use super::*; + use crate::device::DummyDevice; + use hayro_syntax::Pdf; + + fn pdf() -> Pdf { + let objects = ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Count 0 /Kids [] >>"]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(b"xref\n0 3\n0000000000 65535 f \n"); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size 3 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + Pdf::new(out).expect("synthetic pdf") + } + + #[test] + fn repeated_saves_of_a_large_state_stay_bounded_and_balanced() { + let pdf = pdf(); + let cache = InterpreterCache::new(); + let mut ctx = Context::new(Affine::IDENTITY, Rect::new(0.0, 0.0, 100.0, 100.0), &cache, pdf.xref(), InterpreterSettings::default()); + ctx.get_mut().graphics_state.stroke_props.dash_array = crate::types::dash_array((0..crate::types::MAX_DASH_ENTRIES).map(|i| i as f32 + 1.0)); + for _ in 0..20_000 { + ctx.save_state(); + } + assert!(ctx.states.len() <= MAX_SAVED_STATES); + assert!(ctx.saved_bytes <= MAX_SAVED_STATE_BYTES); + assert_eq!(ctx.num_states(), 20_001, "every q still counts"); + + // A clip added after a save that was not kept is removed by its Q. + let mut device = DummyDevice; + let mut triangle = BezPath::new(); + triangle.move_to((10.0, 10.0)); + triangle.line_to((90.0, 10.0)); + triangle.line_to((50.0, 90.0)); + triangle.close_path(); + let clips = ctx.get().clips.len(); + ctx.push_clip_path(triangle, FillRule::NonZero, &mut device); + assert_eq!(ctx.get().clips.len(), clips + 1); + ctx.restore_state(&mut device); + assert_eq!(ctx.get().clips.len(), clips); + + while ctx.num_states() > 1 { + ctx.restore_state(&mut device); + } + assert_eq!(ctx.states.len(), 1); + assert_eq!(ctx.saved_bytes, 0); + } + + #[test] + fn an_overlong_dash_array_strokes_solid() { + let max = crate::types::MAX_DASH_ENTRIES; + assert_eq!(crate::types::dash_array((0..max).map(|_| 2.0)).len(), max); + assert!(crate::types::dash_array((0..max + 1).map(|_| 2.0)).is_empty()); + assert_eq!(crate::types::dash_array([0.0, 3.0].into_iter()).as_slice(), &[0.01, 3.0]); + } +} diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index a0c6308e..8af574c3 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -555,11 +555,9 @@ fn interpret_window<'a>( } TypedInstruction::DashPattern(p) => { context.get_mut().graphics_state.stroke_props.dash_offset = p.1.as_f32(); - // kurbo apparently cannot properly deal with offsets that are exactly 0. + // PdfCraft patch: bounded, see `types::dash_array`. context.get_mut().graphics_state.stroke_props.dash_array = - p.0.iter::() - .map(|n| if n == 0.0 { 0.01 } else { n }) - .collect(); + crate::types::dash_array(p.0.iter::()); } TypedInstruction::RenderingIntent(_) => { // Ignore for now. diff --git a/vendor/hayro-interpret/src/interpret/state.rs b/vendor/hayro-interpret/src/interpret/state.rs index 95b773fb..8488144a 100644 --- a/vendor/hayro-interpret/src/interpret/state.rs +++ b/vendor/hayro-interpret/src/interpret/state.rs @@ -402,11 +402,9 @@ pub(crate) fn handle_gs_single<'a>( let dash_phase = iter.next()?.into_number()?.as_f32(); context.get_mut().graphics_state.stroke_props.dash_offset = dash_phase; - context.get_mut().graphics_state.stroke_props.dash_array = dash_array - .iter::() - // kurbo apparently cannot properly deal with offsets that are exactly 0. - .map(|n| if n == 0.0 { 0.01 } else { n }) - .collect(); + // PdfCraft patch: bounded, see `types::dash_array`. + context.get_mut().graphics_state.stroke_props.dash_array = + crate::types::dash_array(dash_array.iter::()); } "Type" => {} _ => {} diff --git a/vendor/hayro-interpret/src/types.rs b/vendor/hayro-interpret/src/types.rs index 64d2b849..ff7776f3 100644 --- a/vendor/hayro-interpret/src/types.rs +++ b/vendor/hayro-interpret/src/types.rs @@ -305,6 +305,24 @@ pub struct StrokeProps { pub dash_offset: f32, } +/// PdfCraft patch: longest dash array kept. A longer one strokes solid; it is copied by every +/// saved graphics state, and real dash patterns have a handful of entries. +pub(crate) const MAX_DASH_ENTRIES: usize = 256; + +/// PdfCraft patch: the dash array a `d` operator or `/D` entry sets, from its numbers. +pub(crate) fn dash_array(values: impl Iterator) -> SmallVec<[f32; 4]> { + // kurbo apparently cannot properly deal with offsets that are exactly 0. + let dashes: SmallVec<[f32; 4]> = values + .take(MAX_DASH_ENTRIES + 1) + .map(|n| if n == 0.0 { 0.01 } else { n }) + .collect(); + if dashes.len() > MAX_DASH_ENTRIES { + warn!("dash array is too long; stroking solid"); + return smallvec![]; + } + dashes +} + impl Default for StrokeProps { fn default() -> Self { Self { diff --git a/vendor/hayro-syntax/SOURCE.json b/vendor/hayro-syntax/SOURCE.json index 721bac61..462194b2 100644 --- a/vendor/hayro-syntax/SOURCE.json +++ b/vendor/hayro-syntax/SOURCE.json @@ -128,7 +128,7 @@ "path": "src/data.rs", "source_sha256": "c8dc5a157358e024d73e405333ecac01e94ba6880488d5a10b952bd3fc47fd41", "source_git_blob": "9d5e2030f8d6dab7167cd2c5d8da36a7e5688c34", - "sha256": "c8dc5a157358e024d73e405333ecac01e94ba6880488d5a10b952bd3fc47fd41" + "sha256": "83c4417cfe9b301ef0fce8dc2f6a7f694d025e1fd82a68db6acded31155d3bee" }, { "path": "src/filter/ascii_85.rs", @@ -158,7 +158,7 @@ "path": "src/filter/jbig2.rs", "source_sha256": "0ac3c16e02cb13f2d5fe401edd35949320662906c3906cb39fe743acd5e32b09", "source_git_blob": "d87253f536e6b8ac18323f1f06164b516ad775df", - "sha256": "0ac3c16e02cb13f2d5fe401edd35949320662906c3906cb39fe743acd5e32b09" + "sha256": "4ce7d4c4f33528f5922395c99ddf2da67d15f166bfa593c43df5e5ebf75b3669" }, { "path": "src/filter/jpx.rs", @@ -338,7 +338,7 @@ "path": "src/xref.rs", "source_sha256": "51ffeeb7d4ac035bae2cd5ba9288c180088ae06cdcefe2b29dfeda560a793aa0", "source_git_blob": "20e33a410489d42e438a3f2b87d70c7bb8d66eae", - "sha256": "2bd19a3df5f81c4c0babc80a473877714fe13d2542eafb5c8b5843f81af36d80" + "sha256": "d5463a6d629e31a33c1d5b9a735a6e87353752dc0b4156dd84247dcf9b485804" }, { "path": "src/decode_limits_tests.rs", diff --git a/vendor/hayro-syntax/src/data.rs b/vendor/hayro-syntax/src/data.rs index 9d5e2030..7d47c4d3 100644 --- a/vendor/hayro-syntax/src/data.rs +++ b/vendor/hayro-syntax/src/data.rs @@ -68,6 +68,10 @@ pub(crate) struct Data { decoded: SegmentList>, 32>, map: Mutex>, loading: LoadGate, + // PdfCraft patch: each object stream's header pairs, parsed once. Looking up every object of + // a stream otherwise reparsed all /N pairs per lookup, quadratic in /N. An entry is at most + // one pair per four decoded bytes, and decoded streams are kept for the document anyway. + object_offsets: Mutex>>>, } impl Debug for Data { @@ -84,6 +88,7 @@ impl Data { decoded: SegmentList::new(), map: Mutex::new(HashMap::new()), loading: LoadGate::default(), + object_offsets: Mutex::new(HashMap::new()), } } @@ -92,6 +97,20 @@ impl Data { &self.data } + /// PdfCraft patch: the header pairs of object stream `id`, from `parse` on first use. + pub(crate) fn object_offsets( + &self, + id: ObjectIdentifier, + parse: impl FnOnce() -> Option>, + ) -> Option>> { + if let Some(offsets) = self.object_offsets.get().get(&id) { + return Some(offsets.clone()); + } + // Parsed without the lock held; a racing parse of the same stream gives the same table. + let parsed = Arc::new(parse()?); + Some(self.object_offsets.get().entry(id).or_insert(parsed).clone()) + } + /// Get access to the data of a decoded object stream. pub(crate) fn get_with(&self, id: ObjectIdentifier, ctx: &ReaderContext<'_>) -> Option<&[u8]> { // PdfCraft patch: (#307, see vendor/README.md hayro-syntax (10)) register exactly once: diff --git a/vendor/hayro-syntax/src/filter/jbig2.rs b/vendor/hayro-syntax/src/filter/jbig2.rs index d87253f5..a0ab784b 100644 --- a/vendor/hayro-syntax/src/filter/jbig2.rs +++ b/vendor/hayro-syntax/src/filter/jbig2.rs @@ -1,13 +1,19 @@ use crate::bit_reader::BitWriter; +use crate::filter::Filter; use crate::object::Dict; use crate::object::Stream; use crate::object::dict::keys::JBIG2_GLOBALS; use crate::object::stream::{FilterResult, ImageColorSpace, ImageData, ImageDecodeParams}; use alloc::borrow::Cow; -use alloc::vec; use alloc::vec::Vec; use core::iter; +/// PdfCraft patch: most pixels an embedded JBIG2 page may decode to, matching the renderer's +/// `MAX_IMAGE_PIXELS` and `hayro_jbig2`'s region limit. The page header's own dimensions are +/// checked here, before any output is allocated: the image dictionary's smaller ones say nothing +/// about them. +const MAX_PIXELS: u64 = 1 << 28; + /// Decode JBIG2 data from a PDF stream. /// /// The `params` dictionary may contain a `JBIG2Globals` entry pointing to @@ -17,12 +23,28 @@ pub(crate) fn decode( params: &Dict<'_>, image_params: &ImageDecodeParams, ) -> Option> { + // PdfCraft patch: globals are plain segment data. A globals stream filtered as an image + // could name further globals of its own, recursing once per stream in the chain. let globals = params .get::>(JBIG2_GLOBALS) + .filter(|g| { + !g.filters().iter().any(|f| { + matches!( + f, + Filter::Jbig2Decode | Filter::DctDecode | Filter::JpxDecode | Filter::CcittFaxDecode + ) + }) + }) .and_then(|g| g.decoded().ok()); let image = hayro_jbig2::Image::new_embedded(data, globals.as_deref()).ok()?; + if u64::from(image.width()) * u64::from(image.height()) > MAX_PIXELS { + warn!("embedded JBIG2 page is too large"); + + return None; + } + // Whenever possible (if we don't have an indexed color space), we convert // the data as 8-bit instead of 1-bit, so that it can be easier converted // into an RGBA8 image. @@ -31,7 +53,11 @@ pub(crate) fn decode( // white = 0, but PDF uses the opposite. let (decoded, bpc) = if image_params.is_indexed { let row_bytes = (image.width() as usize).div_ceil(8); - let mut packed = vec![0_u8; row_bytes * image.height() as usize]; + // PdfCraft patch: checked and fallible, so a failed allocation is a decode error. + let len = row_bytes.checked_mul(image.height() as usize)?; + let mut packed = Vec::new(); + packed.try_reserve_exact(len).ok()?; + packed.resize(len, 0_u8); struct BitWriterDecoder<'a> { writer: BitWriter<'a>, @@ -77,7 +103,11 @@ pub(crate) fn decode( fn next_line(&mut self) {} } - let mut decoder = Luma8Decoder { output: Vec::new() }; + let mut output = Vec::new(); + output + .try_reserve_exact((image.width() as usize).checked_mul(image.height() as usize)?) + .ok()?; + let mut decoder = Luma8Decoder { output }; image.decode(&mut decoder).ok()?; (decoder.output, 8) @@ -94,3 +124,33 @@ pub(crate) fn decode( }), }) } + +#[cfg(test)] +mod tests { + use super::*; + + /// A page information segment declaring `width` x `height`, and nothing else. + fn page_information(width: u32, height: u32) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&0u32.to_be_bytes()); // segment number + out.push(48); // page information + out.push(0); // no referred-to segments + out.push(1); // page 1 + out.extend_from_slice(&19u32.to_be_bytes()); + out.extend_from_slice(&width.to_be_bytes()); + out.extend_from_slice(&height.to_be_bytes()); + out.extend_from_slice(&[0; 8]); // resolution + out.push(0); // flags + out.extend_from_slice(&[0; 2]); // striping + out + } + + #[test] + fn an_oversized_embedded_page_is_refused_before_allocating() { + let data = page_information(65_535, 65_535); + for is_indexed in [false, true] { + let params = ImageDecodeParams { is_indexed, bpc: Some(1), num_components: Some(1), target_dimension: None, width: 1, height: 1 }; + assert!(decode(&data, &Dict::empty(), ¶ms).is_none()); + } + } +} diff --git a/vendor/hayro-syntax/src/xref.rs b/vendor/hayro-syntax/src/xref.rs index d6f6b33d..ed504128 100644 --- a/vendor/hayro-syntax/src/xref.rs +++ b/vendor/hayro-syntax/src/xref.rs @@ -618,9 +618,13 @@ impl XRef { return None; } - let stream = self.get_with::>(obj_stream_id, &ctx)?; let data = repr.data.get_with(obj_stream_id, &ctx)?; - let object_stream = ObjectStream::new(stream, data, &ctx)?; + // PdfCraft patch: the offset table is parsed once per stream (see Data). + let offsets = repr.data.object_offsets(obj_stream_id, || { + let stream = self.get_with::>(obj_stream_id, &ctx)?; + ObjectStream::offsets(stream, data) + })?; + let object_stream = ObjectStream::with_offsets(offsets, data, &ctx); object_stream.get(index) } } @@ -959,6 +963,22 @@ fn xref_stream_subsection<'a>( f3_len: u8, insert_map: &mut XrefMap, ) -> Option<()> { + // PdfCraft patch: every entry must be backed by stream bytes. With all widths zero an entry + // reads nothing, so a tiny stream could declare billions of entries; and a count past the + // decoded data is refused before any of it is inserted. + let width = f1_len as usize + f2_len as usize + f3_len as usize; + if width == 0 { + warn!("xref stream entries have no width"); + + return None; + } + let available = xref_reader.len().saturating_sub(xref_reader.offset()); + if (num_elements as usize).checked_mul(width).is_none_or(|need| need > available) { + warn!("xref stream subsection declares more entries than its data holds"); + + return None; + } + for i in 0..num_elements { let f_type = if f1_len == 0 { 1 @@ -1064,11 +1084,23 @@ fn get_decryptor(trailer_dict: &Dict<'_>, password: &[u8]) -> Result { data: &'a [u8], ctx: ReaderContext<'a>, - offsets: Vec<(u32, usize)>, + offsets: Arc>, } impl<'a> ObjectStream<'a> { fn new(inner: Stream<'_>, data: &'a [u8], ctx: &ReaderContext<'a>) -> Option { + Some(Self::with_offsets(Arc::new(Self::offsets(inner, data)?), data, ctx)) + } + + fn with_offsets(offsets: Arc>, data: &'a [u8], ctx: &ReaderContext<'a>) -> Self { + let mut ctx = ctx.clone(); + ctx.set_in_object_stream(true); + + Self { data, ctx, offsets } + } + + /// The `/N` pairs of object number and absolute offset at the start of `data`. + fn offsets(inner: Stream<'_>, data: &[u8]) -> Option> { let num_objects = inner.dict().get::(N)?; let first_offset = inner.dict().get::(FIRST)?; @@ -1082,13 +1114,10 @@ impl<'a> ObjectStream<'a> { let obj_num = r.read_without_context::()?; r.skip_white_spaces_and_comments(); let relative_offset = r.read_without_context::()?; - offsets.push((obj_num, first_offset + relative_offset)); + offsets.push((obj_num, first_offset.checked_add(relative_offset)?)); } - let mut ctx = ctx.clone(); - ctx.set_in_object_stream(true); - - Some(Self { data, ctx, offsets }) + Some(offsets) } fn get(&self, index: u32) -> Option @@ -1137,6 +1166,32 @@ fn parse_metadata(info_dict: &Dict<'_>) -> Metadata { mod tests { use super::*; + #[test] + fn xref_stream_entries_need_backing_bytes() { + let mut map = XrefMap::default(); + // All widths zero: an entry reads nothing, so none may be declared. + let mut empty = Reader::new(&[]); + assert!(xref_stream_subsection(&mut empty, 0, u32::MAX, 0, 0, 0, &mut map).is_none()); + // Three entries of width 3 need nine bytes; eight are refused before any insert. + let data = [1, 0, 9, 1, 0, 9, 1, 0]; + let mut short = Reader::new(&data); + assert!(xref_stream_subsection(&mut short, 0, 3, 1, 1, 1, &mut map).is_none()); + assert!(map.is_empty()); + let mut whole = Reader::new(&data[..6]); + assert!(xref_stream_subsection(&mut whole, 0, 2, 1, 1, 1, &mut map).is_some()); + assert_eq!(map.len(), 2); + } + + #[test] + fn object_stream_offsets_are_parsed_once() { + let data = Data::new(PdfData::from(Vec::new())); + let id = ObjectIdentifier::new(7, 0); + let first = data.object_offsets(id, || Some(vec![(1, 10), (2, 20)])).unwrap(); + let again = data.object_offsets(id, || panic!("reparsed a cached offset table")).unwrap(); + assert!(Arc::ptr_eq(&first, &again)); + assert!(data.object_offsets(ObjectIdentifier::new(8, 0), || None).is_none()); + } + // Contributor-original, MIT OR Apache-2.0. Matches the bounded, uncompressed // synthetic regression used to reproduce the cold-publication race. A second // stream optionally checks that racing registration never aliases two ids. diff --git a/vendor/krilla/README.craft.md b/vendor/krilla/README.craft.md index c2e6e7cc..d0aff457 100644 --- a/vendor/krilla/README.craft.md +++ b/vendor/krilla/README.craft.md @@ -26,3 +26,9 @@ The shared writer's native subprocess regression covers the previously aborting cleanup and a successful fresh export afterward. Native and WASM export tests cover the unchanged successful path. WASM aborts, aborting panic configurations and process-wide allocation failures remain outside unwind recovery. + +A PNG whose header implies more than 1 GiB of decoded output is refused by +`Image::from_png` and by the deferred decoder, and the decoded buffer is reserved +fallibly, so a small file (or a bitmap glyph in a font) cannot request an allocation that +ends the process. The colour and alpha buffers that PNG, GIF and WebP decoding split an +image into are reserved fallibly too, and a failure there is an image error. diff --git a/vendor/krilla/SOURCE.json b/vendor/krilla/SOURCE.json index 3b01eaf6..ca911455 100644 --- a/vendor/krilla/SOURCE.json +++ b/vendor/krilla/SOURCE.json @@ -172,7 +172,7 @@ { "path": "src/graphics/image.rs", "upstream_sha256": "61324d6426ac79aa5322d0d33a95532934cd1e41797a9dad6302210a2b799d5d", - "sha256": "61324d6426ac79aa5322d0d33a95532934cd1e41797a9dad6302210a2b799d5d" + "sha256": "25fb30fc591e180f8ab4a6445cbda0d14f1cfc7860c4699b444c3fbaab97bd69" }, { "path": "src/graphics/mask.rs", diff --git a/vendor/krilla/src/graphics/image.rs b/vendor/krilla/src/graphics/image.rs index 22a68ca9..d1d003d4 100644 --- a/vendor/krilla/src/graphics/image.rs +++ b/vendor/krilla/src/graphics/image.rs @@ -330,7 +330,7 @@ impl Image { Self(Arc::new(ImageRepr { inner: Deferred::new(move || { let (color_channel, alpha_channel, bits_per_component) = - handle_u8_image(&data, ColorSpace::RGBA); + handle_u8_image(&data, ColorSpace::RGBA)?; Ok(Repr::Sampled(SampledRepr { color_channel, @@ -522,6 +522,13 @@ fn png_metadata(data: &[u8]) -> Result { let reader = decoder .read_info() .map_err(|e| e.to_string().to_ascii_lowercase())?; + // Craft patch: refused here too, so `Image::from_png` fails instead of the deferred decode. + if reader + .output_buffer_size() + .is_none_or(|n| n > MAX_DECODED_PNG_BYTES) + { + return Err("image is too large".to_string()); + } let info = reader.info(); let size = (info.width, info.height); @@ -555,6 +562,11 @@ fn png_metadata(data: &[u8]) -> Result { }) } +/// Craft patch: most bytes a PNG may decode to (16384 x 16384 RGBA8). The header alone sets the +/// output size, so it is checked before any pixel data, and reserved fallibly, so that a bad +/// image is an error instead of an allocation failure that ends the process. +const MAX_DECODED_PNG_BYTES: usize = 1 << 30; + fn decode_png(data: &[u8]) -> Result { let cursor = Cursor::new(data); let mut decoder = png::Decoder::new(cursor); @@ -562,7 +574,15 @@ fn decode_png(data: &[u8]) -> Result { let mut reader = decoder .read_info() .map_err(|e| e.to_string().to_ascii_lowercase())?; - let mut img_data = vec![0; reader.output_buffer_size().ok_or("image is too large")?]; + let size = reader + .output_buffer_size() + .filter(|&n| n <= MAX_DECODED_PNG_BYTES) + .ok_or("image is too large")?; + let mut img_data = Vec::new(); + img_data + .try_reserve_exact(size) + .map_err(|_| "image is too large".to_string())?; + img_data.resize(size, 0); let _ = reader .next_frame(&mut img_data) .map_err(|e| e.to_string())?; @@ -577,8 +597,8 @@ fn decode_png(data: &[u8]) -> Result { }; let (color_channel, alpha_channel, bits_per_component) = match bit_depth { - BitDepth::Eight => handle_u8_image(&img_data, color_space), - BitDepth::Sixteen => handle_u16_image(&img_data, color_space), + BitDepth::Eight => handle_u8_image(&img_data, color_space)?, + BitDepth::Sixteen => handle_u16_image(&img_data, color_space)?, _ => return Err("image has an unsupported bit-depth".to_string()), }; @@ -663,7 +683,7 @@ fn decode_gif(data: Data) -> Result { .ok_or("GIF image seems to be empty".to_string())?; let (color_channel, alpha_channel, bits_per_component) = - handle_u8_image(first_frame.buffer.as_ref(), ColorSpace::RGBA); + handle_u8_image(first_frame.buffer.as_ref(), ColorSpace::RGBA)?; Ok(Repr::Sampled(SampledRepr { color_channel, @@ -719,7 +739,7 @@ fn decode_webp(data: Data) -> Result { }; let (color_channel, alpha_channel, bits_per_component) = - handle_u8_image(&first_frame, color_space); + handle_u8_image(&first_frame, color_space)?; Ok(Repr::Sampled(SampledRepr { color_channel, @@ -728,9 +748,21 @@ fn decode_webp(data: Data) -> Result { })) } -fn handle_u8_image(data: &[u8], cs: ColorSpace) -> (Vec, Option>, BitsPerComponent) { +/// Craft patch: an empty vector with room for `capacity` bytes, or an error instead of the +/// allocation failure that would end the process. Splitting a decoded image into colour and alpha +/// briefly needs about twice its size beside the decoded pixels. +fn try_buffer(capacity: usize) -> Result, String> { + let mut v = Vec::new(); + v.try_reserve_exact(capacity) + .map_err(|_| "image is too large".to_string())?; + Ok(v) +} + +type SplitImage = (Vec, Option>, BitsPerComponent); + +fn handle_u8_image(data: &[u8], cs: ColorSpace) -> Result { let mut alphas = if cs.has_alpha() { - Vec::with_capacity(data.len() / cs.num_components()) + try_buffer(data.len() / cs.num_components())? } else { Vec::new() }; @@ -738,7 +770,7 @@ fn handle_u8_image(data: &[u8], cs: ColorSpace) -> (Vec, Option>, Bi let color_channel = match cs { ColorSpace::RGB => deflate_encode(data), ColorSpace::RGBA => { - let mut buf = Vec::with_capacity(data.len() * 3 / 4); + let mut buf = try_buffer(data.len() * 3 / 4)?; data.chunks_exact(4).for_each(|data| { buf.extend_from_slice(&data[0..3]); alphas.push(data[3]); @@ -747,7 +779,7 @@ fn handle_u8_image(data: &[u8], cs: ColorSpace) -> (Vec, Option>, Bi } ColorSpace::Luma => deflate_encode(data), ColorSpace::LumaA => { - let mut buf = Vec::with_capacity(data.len() / 2); + let mut buf = try_buffer(data.len() / 2)?; data.chunks_exact(2).for_each(|data| { buf.push(data[0]); alphas.push(data[1]); @@ -764,12 +796,12 @@ fn handle_u8_image(data: &[u8], cs: ColorSpace) -> (Vec, Option>, Bi None }; - (color_channel, alpha_channel, BitsPerComponent::Eight) + Ok((color_channel, alpha_channel, BitsPerComponent::Eight)) } -fn handle_u16_image(data: &[u8], cs: ColorSpace) -> (Vec, Option>, BitsPerComponent) { +fn handle_u16_image(data: &[u8], cs: ColorSpace) -> Result { let mut alphas = if cs.has_alpha() { - Vec::with_capacity(data.len() / cs.num_components()) + try_buffer(data.len() / cs.num_components())? } else { Vec::new() }; @@ -777,7 +809,7 @@ fn handle_u16_image(data: &[u8], cs: ColorSpace) -> (Vec, Option>, B let encoded_image = match cs { ColorSpace::RGB => deflate_encode(data), ColorSpace::RGBA => { - let mut buf = Vec::with_capacity(data.len() * 3 / 4); + let mut buf = try_buffer(data.len() * 3 / 4)?; data.chunks_exact(8).for_each(|data| { buf.extend_from_slice(&data[0..6]); alphas.extend_from_slice(&data[6..]); @@ -786,7 +818,7 @@ fn handle_u16_image(data: &[u8], cs: ColorSpace) -> (Vec, Option>, B } ColorSpace::Luma => deflate_encode(data), ColorSpace::LumaA => { - let mut buf = Vec::with_capacity(data.len() / 2); + let mut buf = try_buffer(data.len() / 2)?; data.chunks_exact(4).for_each(|data| { buf.extend_from_slice(&data[0..2]); alphas.extend_from_slice(&data[2..]); @@ -803,7 +835,7 @@ fn handle_u16_image(data: &[u8], cs: ColorSpace) -> (Vec, Option>, B None }; - (encoded_image, encoded_mask, BitsPerComponent::Sixteen) + Ok((encoded_image, encoded_mask, BitsPerComponent::Sixteen)) } fn get_icc_profile_type(data: &[u8], color_space: ImageColorspace) -> Option { @@ -827,6 +859,54 @@ mod tests { assert!(e.is_err()); } + /// A PNG whose header alone claims `width` x `height` RGBA16, with an empty IDAT. + fn png_header(width: u32, height: u32) -> Vec { + fn crc(bytes: &[u8]) -> u32 { + let mut c = !0u32; + for &b in bytes { + c ^= u32::from(b); + for _ in 0..8 { + c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 }; + } + } + !c + } + fn chunk(out: &mut Vec, kind: &[u8; 4], data: &[u8]) { + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + let start = out.len(); + out.extend_from_slice(kind); + out.extend_from_slice(data); + let sum = crc(&out[start..]); + out.extend_from_slice(&sum.to_be_bytes()); + } + let mut out = b"\x89PNG\r\n\x1a\n".to_vec(); + let mut ihdr = Vec::new(); + ihdr.extend_from_slice(&width.to_be_bytes()); + ihdr.extend_from_slice(&height.to_be_bytes()); + ihdr.extend_from_slice(&[16, 6, 0, 0, 0]); + chunk(&mut out, b"IHDR", &ihdr); + chunk(&mut out, b"IDAT", &[0x78, 0x9C, 0x03, 0x00, 0x00, 0x00, 0x00, 0x01]); + chunk(&mut out, b"IEND", &[]); + out + } + + #[test] + fn a_png_header_claiming_a_huge_image_is_refused_before_allocating() { + // 65535 x 65535 RGBA16 would be 34 GB of output. + let huge = png_header(65_535, 65_535); + assert!(Image::from_png(Arc::new(huge.clone()).into(), false).is_err()); + assert!(super::decode_png(&huge).is_err()); + } + + #[test] + fn rgba_split_buffers_are_reserved_fallibly() { + assert!(super::try_buffer(usize::MAX).is_err()); + let (color, alpha, _) = super::handle_u8_image(&[1, 2, 3, 128, 4, 5, 6, 255], super::ColorSpace::RGBA).unwrap(); + assert!(!color.is_empty() && alpha.is_some()); + let (_, alpha, _) = super::handle_u16_image(&[0; 16], super::ColorSpace::RGBA).unwrap(); + assert!(alpha.is_some()); + } + #[test] fn invalid_jpeg_image() { let e = Image::from_jpeg(Arc::new(b"dfngiudfg".to_vec()).into(), false);