security(export): bound image overlay decoding (#2944)

This commit is contained in:
Earl Co
2026-10-24 04:49:13 -04:00
committed by GitHub
parent a8d074247a
commit 90327492a6
+38 -2
View File
@@ -3,6 +3,7 @@
//! apply the Export effects (image, name and timecode overlays, video limiter) and convert to the
//! encoder's input (straight sRGB RGBA8, or encoded R'G'B' floats for HDR).
use std::io::Cursor;
use std::sync::Arc;
use filmcraft_geom::Vec2;
@@ -14,6 +15,29 @@ use rayon::prelude::*;
use crate::settings::{ExportEffects, Placement, Scaling, TextOverlay};
use crate::{ExportError, ExportSettings, FrameRenderer, GpuRendering, Result, build_frame_renderer, note_gpu_fallback, note_gpu_frame};
/// Image overlays originate outside the project and are expanded to f32 working images. This
/// admits ordinary 4K artwork while keeping both source decode and resampling bounded.
const MAX_OVERLAY_PIXELS: usize = 16 << 20;
fn bounded_overlay_dimensions(w: usize, h: usize) -> Result<()> {
if w == 0 || h == 0 || w.checked_mul(h).is_none_or(|pixels| pixels > MAX_OVERLAY_PIXELS) {
return Err(ExportError::Unsupported("image overlay exceeds the pixel budget".into()));
}
Ok(())
}
#[cfg(test)]
mod overlay_budget_tests {
use super::*;
#[test]
fn overlay_pixel_budget_is_checked_without_overflow() {
assert!(bounded_overlay_dimensions(4096, 4096).is_ok());
assert!(bounded_overlay_dimensions(4097, 4096).is_err());
assert!(bounded_overlay_dimensions(usize::MAX, 2).is_err());
}
}
/// Where the rendered picture lands in the output frame.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Geometry {
@@ -392,8 +416,16 @@ fn composite(dst: &mut Image, src: &Image, x: i64, y: i64, opacity: f32) {
/// Load a PNG / JPEG overlay as premultiplied linear light, `width` pixels wide.
fn load_overlay(path: &str, width: f32) -> Result<Image> {
let bytes = std::fs::read(path).map_err(|e| ExportError::Io(format!("image overlay {path}: {e}")))?;
let rgba = image::load_from_memory(&bytes).map_err(|e| ExportError::Unsupported(format!("image overlay {path}: {e}")))?.to_rgba8();
let mut reader = image::ImageReader::new(Cursor::new(bytes));
let mut limits = image::Limits::default();
limits.max_image_width = Some(16_384);
limits.max_image_height = Some(16_384);
limits.max_alloc = Some((MAX_OVERLAY_PIXELS * 4) as u64);
reader.limits(limits);
let reader = reader.with_guessed_format().map_err(|e| ExportError::Unsupported(format!("image overlay {path}: {e}")))?;
let rgba = reader.decode().map_err(|e| ExportError::Unsupported(format!("image overlay {path}: {e}")))?.to_rgba8();
let (iw, ih) = (rgba.width() as usize, rgba.height() as usize);
bounded_overlay_dimensions(iw, ih)?;
let mut img = Image::new(iw, ih);
for (o, p) in img.px.as_chunks_mut::<4>().0.iter_mut().zip(rgba.as_raw().as_chunks::<4>().0) {
let a = p[3] as f32 / 255.0;
@@ -402,8 +434,12 @@ fn load_overlay(path: &str, width: f32) -> Result<Image> {
}
o[3] = a;
}
if !width.is_finite() || width <= 0.0 {
return Err(ExportError::Unsupported("image overlay width is invalid".into()));
}
let nw = width.round().max(1.0) as usize;
let nh = ((ih as f32 * nw as f32 / iw.max(1) as f32).round() as usize).max(1);
let nh = ((ih as f64 * nw as f64 / iw.max(1) as f64).round() as usize).max(1);
bounded_overlay_dimensions(nw, nh)?;
Ok(if (nw, nh) == (iw, ih) { img } else { resample(&img, nw, nh) })
}