fix(redact): bound image decoding by the declared raster size

Clearing the pixels of a partly covered image decoded its stream with
the global 1 GiB allowance and only then cut it to the raster length its
Width, Height and bit depth declare. A small Flate stream could make
every such image allocate and inflate far more than it can use. Decoding
now stops at twice the raster plus a byte per row (filter-chain and
predictor overhead) and a little slack; an image past that is removed
whole, as an undecodable one already is.
This commit is contained in:
aiongg
2026-10-22 15:17:44 -04:00
committed by Dylan M. Taylor
parent be64a60c87
commit cccd435a53
3 changed files with 31 additions and 1 deletions
+5 -1
View File
@@ -77,7 +77,11 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]
let bytes = row.checked_mul(h)?;
let row_bits = row.checked_mul(8)?;
bytes.checked_mul(8)?;
let mut data = s.decoded().ok()?;
// Decode no further than the raster can need: a filter stage may legitimately produce up to
// twice it (ASCIIHex text under Flate) plus a PNG predictor's byte per row. Data past that
// can only be a decompression bomb; the image is then removed whole.
let allowance = bytes.checked_add(h)?.checked_mul(2)?.checked_add(4096)?;
let mut data = s.decoded_within(allowance).ok()?;
if data.len() < bytes {
return None;
}
+1
View File
@@ -2461,6 +2461,7 @@ fn unused_descriptions_in_shared_or_inherited_resources_are_retired() {
}
}
mod limits;
mod property_retirement;
mod surviving_references;
mod text_state;
+25
View File
@@ -0,0 +1,25 @@
//! Work and memory stay bounded by what the page declares, whatever the file holds.
use super::*;
/// A half-covered 4×1 grey image whose Flate data decodes to `decoded`.
fn half_covered_image(decoded: &[u8]) -> Report {
let dict = "/Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8";
let mut doc = one_page(b"q 40 0 0 10 100 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(dict, b"")]);
let Object::Stream(s) = &*doc.get(ObjRef::new(7, 0)) else { panic!() };
doc.set(ObjRef::new(7, 0), Object::Stream(Stream::flate(s.dict.clone(), decoded)));
mark(&mut doc, 0, &[[95.0, 95.0, 120.0, 115.0]], "");
apply(&mut doc, None).unwrap()
}
#[test]
fn an_image_decoding_far_past_its_raster_is_removed_without_decoding_it_all() {
let r = half_covered_image(&[255; 4]);
assert_eq!((r.images_removed, r.images_cleared), (0, 1), "{r:?}");
// 4 bytes of raster, then 4 MiB the image never uses: decoding stops at a bound derived from
// the raster, and the image goes whole.
let mut bomb = vec![255; 4];
bomb.resize(4 << 20, 0);
let r = half_covered_image(&bomb);
assert_eq!((r.images_removed, r.images_cleared), (1, 0), "{r:?}");
}