mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 03:27:14 -06:00
Merge pull request #2770 from dylanmtaylor/security/heif
security: bound heic-rs 0.1.4 work around hostile HEIF containers
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
//! Caps on the container's metadata boxes, counted before heic-rs touches the file.
|
||||
//!
|
||||
//! heic-rs 0.1.4 (pinned `=0.1.4`) has no aggregate work budget inside its container parsing:
|
||||
//! every `ipma` association entry rescans the vector of already-seen item ids (quadratic in
|
||||
//! the number of entries), and an `iloc` extent using construction method 2 reconstructs its
|
||||
//! source item afresh for every extent that references it, with no shared cache. Legitimate
|
||||
//! phone-photo metadata never comes near these caps — real corpora keep both well under a few
|
||||
//! kilobytes — so anything above them is hostile or corrupt and is refused before the
|
||||
//! countable CPU work happens.
|
||||
//!
|
||||
//! [`depth_boxes`] stops silently at structural blips: heic-rs's own parser reports those
|
||||
//! better than this walk could, and an `ipma` box past the cap is caught wherever it parses.
|
||||
|
||||
use heic_rs::boxes::BoxIter;
|
||||
|
||||
use crate::Error;
|
||||
|
||||
/// The largest `ipma` box (item ↔ property associations) payload accepted, 64 KiB. An entry
|
||||
/// costs at least 3 bytes, so this bounds heic-rs's per-entry rescan of earlier item ids to
|
||||
/// ~21 000 entries ≈ 220 million comparisons: long enough to be annoying, never minutes.
|
||||
const MAX_IPMA_BYTES: usize = 64 << 10;
|
||||
|
||||
/// The largest `iloc` box (item locations) payload accepted, 64 KiB. An extent costs at least
|
||||
/// 12 bytes, so this bounds both the parse itself and how many extents a
|
||||
/// construction-method-2 reconstruction chain can visit.
|
||||
const MAX_ILOC_BYTES: usize = 64 << 10;
|
||||
|
||||
/// Falls through unless the container declares more `ipma` or `iloc` payload than a
|
||||
/// legitimate phone photo ever does.
|
||||
pub fn cap(bytes: &[u8]) -> Result<(), Error> {
|
||||
let mut c = Census::default();
|
||||
depth_boxes(&mut c, BoxIter::new(bytes), 0);
|
||||
if c.ipma > MAX_IPMA_BYTES {
|
||||
return Err(Error::Limit(format!("the image carries {} bytes of property associations, over the {MAX_IPMA_BYTES}-byte cap", c.ipma)));
|
||||
}
|
||||
if c.iloc > MAX_ILOC_BYTES {
|
||||
return Err(Error::Limit(format!("the image carries {} bytes of item locations, over the {MAX_ILOC_BYTES}-byte cap", c.iloc)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The metadata-box payload sizes seen so far.
|
||||
#[derive(Default)]
|
||||
struct Census {
|
||||
ipma: usize,
|
||||
iloc: usize,
|
||||
}
|
||||
|
||||
/// Walks boxes, adding the payload sizes of those named in [`Census`].
|
||||
///
|
||||
/// Only `meta` and `iprp` (the two nesting boxes the metadata is built from) are descended
|
||||
/// into, no deeper than that depth, so a nested-box trick can't hide a box from the count.
|
||||
fn depth_boxes(c: &mut Census, boxes: BoxIter<'_>, depth: u32) {
|
||||
if depth > 3 {
|
||||
return;
|
||||
}
|
||||
for b in boxes {
|
||||
// A structural error stops the walk; `heic_rs::probe` reports it properly afterwards.
|
||||
let Ok(b) = b else { return };
|
||||
if b.is(b"ipma") {
|
||||
c.ipma = c.ipma.saturating_add(b.payload.len());
|
||||
} else if b.is(b"iloc") {
|
||||
c.iloc = c.iloc.saturating_add(b.payload.len());
|
||||
} else if b.is(b"meta") {
|
||||
// `meta` is a FullBox: version and flags sit between its header and its children.
|
||||
if b.payload.len() >= 4 {
|
||||
depth_boxes(c, BoxIter::new(&b.payload[4..]), depth + 1);
|
||||
}
|
||||
} else if b.is(b"iprp") {
|
||||
depth_boxes(c, b.children(), depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Assembles ftyp + meta(version/flags + iprp(ipco, ipma)) by hand; only the structure
|
||||
/// the census walks matters, no item is ever defined.
|
||||
fn file_with(ipma_payload: &[u8]) -> Vec<u8> {
|
||||
let mut out = b"\0\0\0\x18ftypheic\0\0\0\0mif1heic".to_vec();
|
||||
let mut iprp = Vec::new();
|
||||
iprp.extend(box_of(b"ipco", b""));
|
||||
iprp.extend(box_of(b"ipma", ipma_payload));
|
||||
let mut meta = vec![0u8, 0, 0, 0]; // version 0, flags 0
|
||||
meta.extend(box_of(b"iprp", &iprp));
|
||||
out.extend(box_of(b"meta", &meta));
|
||||
out
|
||||
}
|
||||
|
||||
fn box_of(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(8 + body.len());
|
||||
let size = u32::try_from(8 + body.len()).expect("test box fits in u32");
|
||||
out.extend_from_slice(&size.to_be_bytes());
|
||||
out.extend_from_slice(kind);
|
||||
out.extend_from_slice(body);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_oversized_ipma_is_refused() {
|
||||
// 40 000 entries of 6 bytes: 240 KiB of ipma, far over the cap, and quadratic if
|
||||
// parsed (each entry rescans the earlier item ids in heic-rs 0.1.4).
|
||||
let mut ipma = Vec::new();
|
||||
for _ in 0..40_000u32 {
|
||||
ipma.extend_from_slice(&[0u8, 0, 0, 0, 0, 0]);
|
||||
}
|
||||
let bytes = file_with(&ipma);
|
||||
let r = crate::probe(&bytes).map(|_| ());
|
||||
assert!(matches!(r, Err(Error::Limit(ref m)) if m.contains("associations")), "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_undersized_ipma_is_untouched_by_the_caps() {
|
||||
// The same shape with a normal ipma (two entries): the caps pass, and whatever error
|
||||
// follows must not come from them.
|
||||
let bytes = file_with(&[0, 0, 4, 1, 1, 2, 0, 0]);
|
||||
let r = crate::probe(&bytes);
|
||||
assert!(!matches!(r, Err(Error::Limit(ref m)) if m.contains("cap")), "{r:?}");
|
||||
}
|
||||
}
|
||||
+108
-9
@@ -19,6 +19,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod census;
|
||||
mod orient;
|
||||
/// Lossless synthetic HEIF files for tests, here and in the crates above (feature `testdata`).
|
||||
#[cfg(any(test, feature = "testdata"))]
|
||||
@@ -149,20 +150,50 @@ pub fn probe(bytes: &[u8]) -> Result<Info, Error> {
|
||||
}
|
||||
|
||||
fn probe_unguarded(bytes: &[u8]) -> Result<Info, Error> {
|
||||
let info = heic_rs::probe(bytes).map_err(|e| match e {
|
||||
// No `meta` box: no still image, only a `moov` image sequence (a video track).
|
||||
census::cap(bytes)?;
|
||||
// No `meta` box: no still image, only a `moov` image sequence (a video track).
|
||||
let open_error = |e| match e {
|
||||
heic_rs::Error::MissingBox("meta") => Error::Unsupported(SEQUENCE),
|
||||
e => err(e),
|
||||
})?;
|
||||
let ctx = Context::open(bytes).map_err(err)?;
|
||||
};
|
||||
let ctx = Context::open(bytes).map_err(open_error)?;
|
||||
let id = ctx.meta.primary;
|
||||
let coded = match ctx.grid(id).map_err(err)? {
|
||||
// heic-rs 0.1.4 caps the size of what it assembles, not the work: an item built from other
|
||||
// items (`iloc` construction method 2) is rebuilt once per extent, so a chain of such items
|
||||
// multiplies at every level while each result stays small. Count those visits from the
|
||||
// `iloc` table before reconstructing anything: the primary item first (`heic_rs::probe` and
|
||||
// `Context::grid` both rebuild its descriptor), then its alpha plane, then every tile
|
||||
// reference, repeats included.
|
||||
let mut visits = 0_usize;
|
||||
item_visits(&ctx, id, 0, &mut visits)?;
|
||||
let info = heic_rs::probe(bytes).map_err(open_error)?;
|
||||
if let Some(alpha) = ctx.alpha_item(id).map_err(err)? {
|
||||
item_visits(&ctx, alpha, 0, &mut visits)?;
|
||||
}
|
||||
let mut coded = match ctx.grid(id).map_err(err)? {
|
||||
Some((_, tiles)) => tiles,
|
||||
None => vec![id],
|
||||
};
|
||||
for &item in coded.iter().filter(|&&item| item != id) {
|
||||
item_visits(&ctx, item, 0, &mut visits)?;
|
||||
}
|
||||
// A `dimg` listing may name the same tile more than once, and reconstructable items are
|
||||
// moreover rebuilt for every extent that references them in heic-rs 0.1.4. For the size
|
||||
// check each unique item counts once: the total reconstruction (all of it assembled from
|
||||
// `bytes` by heic-rs) is held below one copy of the file, above which a repeat or chain
|
||||
// wants more bytes than exist. Order doesn't matter here: this only checks that every
|
||||
// tile's bytes lie in the file.
|
||||
coded.sort_unstable();
|
||||
coded.dedup();
|
||||
let mut reconstructed = 0_usize;
|
||||
for item in coded {
|
||||
// Borrowed from `bytes` unless split across extents: cheap.
|
||||
ctx.item_data(item).map_err(err)?;
|
||||
reconstructed = reconstructed.saturating_add(ctx.item_data(item).map_err(err)?.len());
|
||||
if reconstructed > bytes.len() {
|
||||
return Err(Error::Limit(format!(
|
||||
"the container holds {reconstructed} bytes of item data, over the {} bytes of the file itself",
|
||||
bytes.len()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(Info {
|
||||
width: info.width,
|
||||
@@ -174,6 +205,36 @@ fn probe_unguarded(bytes: &[u8]) -> Result<Info, Error> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Most extent visits heic-rs may spend reconstructing the primary image's coded items; real
|
||||
/// files need one per tile (plus a few for the rare derived item).
|
||||
const MAX_ITEM_VISITS: usize = 4096;
|
||||
/// Deepest chain of items built from items we follow (heic-rs refuses beyond eight itself).
|
||||
const MAX_ITEM_CHAIN: u32 = 8;
|
||||
|
||||
/// Adds to `visits` the extents heic-rs would read to reconstruct `id`, following items built
|
||||
/// from other items, and fails once that passes [`MAX_ITEM_VISITS`] or the chain is too deep.
|
||||
fn item_visits(ctx: &Context<'_>, id: u32, depth: u32, visits: &mut usize) -> Result<(), Error> {
|
||||
use heic_rs::meta::iloc::Construction;
|
||||
if depth > MAX_ITEM_CHAIN {
|
||||
return Err(Error::Limit(format!("items built from items nest deeper than {MAX_ITEM_CHAIN} levels")));
|
||||
}
|
||||
let Some(loc) = ctx.meta.location(id) else { return Ok(()) };
|
||||
*visits = visits.saturating_add(loc.extents.len().max(1));
|
||||
if *visits > MAX_ITEM_VISITS {
|
||||
return Err(Error::Limit(format!("reconstructing the image takes over {MAX_ITEM_VISITS} item extent reads")));
|
||||
}
|
||||
if loc.construction == Construction::Item {
|
||||
for e in &loc.extents {
|
||||
if let Ok(source) = u32::try_from(e.index)
|
||||
&& source != id
|
||||
{
|
||||
item_visits(ctx, source, depth + 1, visits)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decodes the primary image, with its metadata.
|
||||
pub fn decode(bytes: &[u8], options: &Options) -> Result<Decoded, Error> {
|
||||
guarded(|| decode_unguarded(bytes, options))
|
||||
@@ -188,7 +249,13 @@ pub fn decode_thumbnail(bytes: &[u8], min_edge: u32, options: &Options) -> Resul
|
||||
let ctx = Context::open(bytes).map_err(err)?;
|
||||
let primary = ctx.meta.primary;
|
||||
let mut best: Option<(u32, u32)> = None;
|
||||
// A thumbnail whose own reconstruction would cost too much is not a candidate; the visits
|
||||
// of all the candidates share one budget.
|
||||
let mut candidate_visits = 0_usize;
|
||||
for r in ctx.meta.refs.iter().filter(|r| r.kind == heic_rs::meta::iref::RefKind::Thmb && r.to.contains(&primary)) {
|
||||
if item_visits(&ctx, r.from, 0, &mut candidate_visits).is_err() {
|
||||
continue;
|
||||
}
|
||||
let Ok(p) = ctx.props(r.from) else { continue };
|
||||
let Ok((w, h)) = ctx.coded_size(r.from, &p) else { continue };
|
||||
let edge = w.max(h);
|
||||
@@ -197,6 +264,15 @@ pub fn decode_thumbnail(bytes: &[u8], min_edge: u32, options: &Options) -> Resul
|
||||
}
|
||||
}
|
||||
let Some((id, _)) = best else { return Ok(None) };
|
||||
// The same pre-scan as `probe`: the thumbnail's own descriptor and every tile reference
|
||||
// are counted before heic-rs rebuilds any of them.
|
||||
let mut visits = 0_usize;
|
||||
item_visits(&ctx, id, 0, &mut visits)?;
|
||||
if let Some((_, tiles)) = ctx.grid(id).map_err(err)? {
|
||||
for &tile in &tiles {
|
||||
item_visits(&ctx, tile, 0, &mut visits)?;
|
||||
}
|
||||
}
|
||||
let mut decoded = decode_item(&ctx, id, options, false)?;
|
||||
if decoded.icc.is_none() {
|
||||
decoded.icc = item_icc(&ctx, primary);
|
||||
@@ -232,6 +308,13 @@ fn decode_unguarded(bytes: &[u8], options: &Options) -> Result<Decoded, Error> {
|
||||
/// `with_alpha` and the item has an alpha auxiliary image.
|
||||
fn decode_item(ctx: &Context<'_>, id: u32, options: &Options, with_alpha: bool) -> Result<Decoded, Error> {
|
||||
let p = ctx.props(id).map_err(err)?;
|
||||
// heic-rs 0.1.4 keeps every associated transform and its [`orient::apply`] allocates the
|
||||
// full picture again for each: a file could chain the repetition and multiply decode CPU
|
||||
// without changing any checked size. Real files carry at most one `clap`, `irot` and
|
||||
// `imir`, so refuse anything approaching [`MAX_TRANSFORMS`] (CodeRabbit #44).
|
||||
if p.transforms.len() > MAX_TRANSFORMS {
|
||||
return Err(Error::Limit(format!("the image carries {} transform properties, over the {MAX_TRANSFORMS}-property cap", p.transforms.len())));
|
||||
}
|
||||
let limit = options.max_pixels;
|
||||
// The declared sizes are refused before anything is decoded or allocated.
|
||||
let (cw, ch) = ctx.coded_size(id, &p).map_err(err)?;
|
||||
@@ -245,6 +328,14 @@ fn decode_item(ctx: &Context<'_>, id: u32, options: &Options, with_alpha: bool)
|
||||
let signal = ycc::Signal { matrix: colour.matrix, full_range: colour.full_range };
|
||||
let alpha_id = if with_alpha { ctx.alpha_item(id).map_err(err)? } else { None };
|
||||
let (w, h) = planes.size();
|
||||
// A grid's canvas dimensions come from its payload, not the checked `ispe` (CodeRabbit
|
||||
// #48): check the real one, before [`ycc::to_rgb16`] allocates for it.
|
||||
match (u32::try_from(w), u32::try_from(h)) {
|
||||
(Ok(wu), Ok(hu)) => {
|
||||
heic_rs::image::check_pixels(wu, hu, limit).map_err(err)?;
|
||||
}
|
||||
_ => return Err(Error::Limit(format!("the image canvas is {w}x{h}, beyond the {limit}-pixel limit"))),
|
||||
}
|
||||
let mode = if options.apply_transforms && orient::odd_crop(w, h, &p.transforms)? { ycc::Upsampling::Bilinear } else { ycc::Upsampling::Nearest };
|
||||
let mut data = ycc::to_rgb16(&planes, signal, alpha_id.is_some(), mode)
|
||||
.ok_or_else(|| Error::Malformed("the decoded pictures don't cover the image".into()))?;
|
||||
@@ -281,6 +372,10 @@ fn decode_item(ctx: &Context<'_>, id: u32, options: &Options, with_alpha: bool)
|
||||
/// cover its canvas rounded up to whole tiles, so legitimate grids sit near 1.
|
||||
const GRID_BUDGET: u64 = 2;
|
||||
|
||||
/// The most transform properties (`clap`/`irot`/`imir`) one item may carry. Real files have at
|
||||
/// most one of each, so anything approaching this is a reordered or hostile association list.
|
||||
const MAX_TRANSFORMS: usize = 12;
|
||||
|
||||
/// The decoded pictures of an item: one, or a grid's tiles in `dimg` order.
|
||||
struct Coded<'a> {
|
||||
frames: Vec<Frame>,
|
||||
@@ -370,8 +465,12 @@ fn colour(p: &ItemProps<'_>, coded: &ItemProps<'_>) -> Colour {
|
||||
/// metadata item never fails a decode whose pixels came out fine.
|
||||
fn metadata(ctx: &Context<'_>) -> (Option<Vec<u8>>, Option<String>) {
|
||||
let id = ctx.meta.primary;
|
||||
let exif = ctx.exif(id).ok().flatten().map(<[u8]>::to_vec);
|
||||
let xmp = ctx.xmp_item(id).and_then(|x| ctx.item_data(x).ok()).and_then(|d| match d {
|
||||
// Metadata is best effort, so an item that would cost too much to rebuild is skipped, not an error.
|
||||
let mut visits = 0_usize;
|
||||
let cheap = |item: Option<u32>, visits: &mut usize| item.is_none_or(|item| item_visits(ctx, item, 0, visits).is_ok());
|
||||
let exif = if cheap(ctx.exif_item(id), &mut visits) { ctx.exif(id).ok().flatten().map(<[u8]>::to_vec) } else { None };
|
||||
let xmp_item = ctx.xmp_item(id).filter(|&x| cheap(Some(x), &mut visits));
|
||||
let xmp = xmp_item.and_then(|x| ctx.item_data(x).ok()).and_then(|d| match d {
|
||||
Cow::Borrowed(b) => String::from_utf8(b.to_vec()).ok(),
|
||||
Cow::Owned(b) => String::from_utf8(b).ok(),
|
||||
});
|
||||
|
||||
@@ -185,3 +185,16 @@ fn truncated_and_corrupted_files_are_errors_not_panics() {
|
||||
let _ = decode_thumbnail(&b, 1, &Options::default());
|
||||
}
|
||||
}
|
||||
|
||||
/// CodeRabbit #44: heic-rs keeps every associated transform and each one re-allocates the
|
||||
/// whole picture, so 13 of them are over the [`crate::MAX_TRANSFORMS`] cap and refused.
|
||||
#[test]
|
||||
fn too_many_transform_properties_are_refused() {
|
||||
let bytes = build(&Spec { props: std::iter::repeat_n(Prop::Irot(0), 13).collect(), ..Spec::new(32, 32, &grey) });
|
||||
let r = decode(&bytes, &Options::default());
|
||||
assert!(matches!(r, Err(Error::Limit(ref m)) if m.contains("transform")), "{r:?}");
|
||||
// At the cap they're not: 12 identity rotations decode normally.
|
||||
let bytes = build(&Spec { props: std::iter::repeat_n(Prop::Irot(0), 12).collect(), ..Spec::new(32, 32, &grey) });
|
||||
let d = decode(&bytes, &Options::default()).unwrap();
|
||||
assert_eq!((d.width, d.height), (32, 32));
|
||||
}
|
||||
|
||||
@@ -161,6 +161,13 @@ pub fn to_rgb16(planes: &Planes<'_>, signal: Signal, alpha: bool, mode: Upsampli
|
||||
if !(8..=16).contains(&bd) || w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
// The default caller limit is 2²8 pixels, at most four channels per pixel, so the largest
|
||||
// legitimate `w·h·ch` is 2²30 samples. Anything more is a malformed or hostile canvas the
|
||||
// decode path would have refused before calling here (CodeRabbit #48): refuse rather than
|
||||
// ask for a multi-gigabuffer (or worse, over usize).
|
||||
if w.checked_mul(h)?.checked_mul(ch)? > 1 << 30 {
|
||||
return None;
|
||||
}
|
||||
let max = ((1u32 << bd) - 1) as f32;
|
||||
let half = (1u32 << (bd - 1)) as f32;
|
||||
let scale = (1u32 << (bd - 8)) as f32;
|
||||
@@ -323,4 +330,23 @@ mod tests {
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
/// CodeRabbit #48: a plane claiming 500 000 × 500 000 pixels must be refused without ever
|
||||
/// allocating for it (before the cap this vec was never built; upper-bounding allocation
|
||||
/// would have been a 7.5-gibabuffer or a panic instead of an error).
|
||||
#[test]
|
||||
fn an_oversized_canvas_is_refused_without_allocation() {
|
||||
let f = Frame {
|
||||
width: 500_000,
|
||||
height: 500_000,
|
||||
bit_depth: 8,
|
||||
chroma: ChromaFormat::Yuv420,
|
||||
y: vec![128],
|
||||
cb: vec![128; 4],
|
||||
cr: vec![128; 4],
|
||||
y_stride: 0,
|
||||
c_stride: 0,
|
||||
};
|
||||
assert!(to_rgb16(&Planes::Picture(&f), Signal { matrix: 6, full_range: true }, false, Upsampling::Nearest).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user