security: bound vendored hayro xref/object-stream/JBIG2/graphics-state work and krilla PNG size (CodeRabbit) (#239)

* security: bound vendored hayro xref, object-stream, JBIG2 and graphics-state work; krilla PNG size

hayro-syntax:
- xref-stream subsections must be backed by decoded bytes: zero /W widths and counts
  longer than the data are refused before inserting (CodeRabbit CWE-400, xref.rs:962)
- object-stream offset tables are parsed once per document and cached in Data
  (CodeRabbit CWE-407, xref.rs:1079)
- embedded JBIG2 pages over 2^28 pixels are refused before allocating, with checked,
  fallible reservation (CodeRabbit CWE-789, filter/jbig2.rs:34)
- a JBIG2Globals stream with an image filter is ignored, so globals cannot recurse
  (CodeRabbit CWE-674, filter/jbig2.rs:20)

hayro-interpret:
- at most 4096 saved graphics states / 64 MiB retained; dash arrays over 256 entries stroke
  solid (CodeRabbit CWE-400, context.rs:226)

krilla:
- PNGs implying more than 1 GiB of decoded output are refused at Image::from_png and in
  the deferred decoder; the buffer is reserved fallibly (CodeRabbit CWE-789,
  graphics/image.rs:565)

SOURCE.json local hashes updated; patches recorded in vendor/README.craft-reader.md and
vendor/krilla/README.craft.md.

* vendor: fix SOURCE.json local hashes; reserve krilla's colour/alpha split buffers fallibly

- SOURCE.json: only the local sha256 changes; the source/upstream hashes of the patched
  files are restored (CodeRabbit review)
- krilla handle_u8_image/handle_u16_image reserve their split buffers with try_reserve_exact
  and return an error, so a valid image under the 1 GiB decode cap cannot abort the process
  on the second allocation (CodeRabbit review)
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-10-23 08:39:26 -04:00
1 parent 4919088427
commit 6cefd467fc
13 files changed
+407 -46

No files matched your search

+14
View File
@@ -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
+4 -4
View File
@@ -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",
+115 -2
View File
@@ -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<usize>,
}
/// 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<'_>>()
+ state.graphics_state.stroke_props.dash_array.len() * size_of::<f32>()
+ state.clips.len() * size_of::<ClipType>()
}
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<Rect> {
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]);
}
}
+2 -4
View File
@@ -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::<f32>()
.map(|n| if n == 0.0 { 0.01 } else { n })
.collect();
crate::types::dash_array(p.0.iter::<f32>());
}
TypedInstruction::RenderingIntent(_) => {
// Ignore for now.
+3 -5
View File
@@ -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::<f32>()
// 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::<f32>());
}
"Type" => {}
_ => {}
+18
View File
@@ -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<Item = f32>) -> 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 {
+3 -3
View File
@@ -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",
+19
View File
@@ -68,6 +68,10 @@ pub(crate) struct Data {
decoded: SegmentList<Option<Vec<u8>>, 32>,
map: Mutex<HashMap<ObjectIdentifier, usize>>,
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<HashMap<ObjectIdentifier, Arc<Vec<(u32, usize)>>>>,
}
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<Vec<(u32, usize)>>,
) -> Option<Arc<Vec<(u32, usize)>>> {
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:
+63 -3
View File
@@ -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<FilterResult<'static>> {
// 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::<Stream<'_>>(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<u8> {
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(), &params).is_none());
}
}
}
+63 -8
View File
@@ -618,9 +618,13 @@ impl XRef {
return None;
}
let stream = self.get_with::<Stream<'_>>(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::<Stream<'_>>(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<Decryptor,
struct ObjectStream<'a> {
data: &'a [u8],
ctx: ReaderContext<'a>,
offsets: Vec<(u32, usize)>,
offsets: Arc<Vec<(u32, usize)>>,
}
impl<'a> ObjectStream<'a> {
fn new(inner: Stream<'_>, data: &'a [u8], ctx: &ReaderContext<'a>) -> Option<Self> {
Some(Self::with_offsets(Arc::new(Self::offsets(inner, data)?), data, ctx))
}
fn with_offsets(offsets: Arc<Vec<(u32, usize)>>, 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<Vec<(u32, usize)>> {
let num_objects = inner.dict().get::<usize>(N)?;
let first_offset = inner.dict().get::<usize>(FIRST)?;
@@ -1082,13 +1114,10 @@ impl<'a> ObjectStream<'a> {
let obj_num = r.read_without_context::<u32>()?;
r.skip_white_spaces_and_comments();
let relative_offset = r.read_without_context::<usize>()?;
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<T>(&self, index: u32) -> Option<T>
@@ -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.
+6
View File
@@ -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.
+1 -1
View File
@@ -172,7 +172,7 @@
{
"path": "src/graphics/image.rs",
"upstream_sha256": "61324d6426ac79aa5322d0d33a95532934cd1e41797a9dad6302210a2b799d5d",
"sha256": "61324d6426ac79aa5322d0d33a95532934cd1e41797a9dad6302210a2b799d5d"
"sha256": "25fb30fc591e180f8ab4a6445cbda0d14f1cfc7860c4699b444c3fbaab97bd69"
},
{
"path": "src/graphics/mask.rs",
+96 -16
View File
@@ -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<ImageMetadata, String> {
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<ImageMetadata, String> {
})
}
/// 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<Repr, String> {
let cursor = Cursor::new(data);
let mut decoder = png::Decoder::new(cursor);
@@ -562,7 +574,15 @@ fn decode_png(data: &[u8]) -> Result<Repr, String> {
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<Repr, String> {
};
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<Repr, String> {
.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<Repr, String> {
};
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<Repr, String> {
}))
}
fn handle_u8_image(data: &[u8], cs: ColorSpace) -> (Vec<u8>, Option<Vec<u8>>, 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<Vec<u8>, String> {
let mut v = Vec::new();
v.try_reserve_exact(capacity)
.map_err(|_| "image is too large".to_string())?;
Ok(v)
}
type SplitImage = (Vec<u8>, Option<Vec<u8>>, BitsPerComponent);
fn handle_u8_image(data: &[u8], cs: ColorSpace) -> Result<SplitImage, String> {
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<u8>, Option<Vec<u8>>, 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<u8>, Option<Vec<u8>>, 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<u8>, Option<Vec<u8>>, Bi
None
};
(color_channel, alpha_channel, BitsPerComponent::Eight)
Ok((color_channel, alpha_channel, BitsPerComponent::Eight))
}
fn handle_u16_image(data: &[u8], cs: ColorSpace) -> (Vec<u8>, Option<Vec<u8>>, BitsPerComponent) {
fn handle_u16_image(data: &[u8], cs: ColorSpace) -> Result<SplitImage, String> {
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<u8>, Option<Vec<u8>>, 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<u8>, Option<Vec<u8>>, 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<u8>, Option<Vec<u8>>, 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<GenericICCProfile> {
@@ -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<u8> {
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<u8>, 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);