fix(media-proxy): bound animated transforms by output pixels (#3402)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-11 18:46:34 +02:00
1 parent 50c28aa8fa
commit 5a2d81ebbb
8 files changed
+72 -10

No files matched your search

+1 -1
View File
@@ -16,7 +16,7 @@ pub const MAX_ANIMATED_FRAMES_DEFAULT: u32 = 20_000;
pub const MAX_ANIMATED_TOTAL_PIXELS_DEFAULT: usize = 4 * MAX_MEDIA_IMAGE_PIXELS_DEFAULT;
pub const MAX_JXL_DECODE_PIXELS: usize = 4096 * 4096;
pub const MAX_STATIC_OUTPUT_PIXELS: usize = 6144 * 4096;
pub const MAX_GIF_RESIZE_OUTPUT_PIXELS: usize = 16 << 20;
pub const MAX_ANIMATED_OUTPUT_PIXELS: usize = 16 << 20;
pub const SVG_RASTER_MAX_DIMENSION: u32 = 4096;
pub const LARGE_SOURCE_LOG_PIXELS: usize = 4096 * 4096;
const _: () = assert!(MAX_ANIMATED_FRAMES_DEFAULT >= 20_000);
@@ -182,7 +182,7 @@ impl GifResizeDims {
(self.width as usize)
.saturating_mul(self.height as usize)
.saturating_mul(frames.max(1) as usize)
<= constants::MAX_GIF_RESIZE_OUTPUT_PIXELS
<= constants::MAX_ANIMATED_OUTPUT_PIXELS
}
}
@@ -13,8 +13,8 @@ use super::image_probe::{
};
use super::loaded_image::{
normalize_vips_image_to_uchar, page_height, resize_loaded_image, resize_loaded_image_by_scale,
validate_dimensions_u32, validate_jxl_decode_pixels, validate_static_output_pixels,
validate_vips_image,
static_output_pixels, validate_dimensions_u32, validate_jxl_decode_pixels,
validate_static_output_pixels, validate_vips_image,
};
use super::native_runtime::{clear_vips_error, ensure_vips_init, last_vips_error};
use super::transform_plan::{
@@ -128,6 +128,31 @@ fn ensure_decode_cost_admissible(
admitted
}
fn ensure_animated_output_admissible(
input: &[u8],
options: &ImageOptions,
media_limits: &MediaLimits,
) -> Result<(), MediaError> {
let dims = probe_image_dims(media_limits, input)?;
if dims.pages <= 1 {
return Ok(());
}
let output_pixels = static_output_pixels(options, dims).saturating_mul(dims.pages as usize);
if output_pixels <= constants::MAX_ANIMATED_OUTPUT_PIXELS {
return Ok(());
}
tracing::warn!(
input_bytes = input.len(),
width = dims.width,
height = dims.height,
pages = dims.pages,
out_width = options.width.unwrap_or(0),
out_height = options.height.unwrap_or(0),
"animated transform refused by the output work budget"
);
Err(MediaError::TooLargeToProcess)
}
pub fn transform_image(
input: &[u8],
options: &ImageOptions,
@@ -184,6 +209,9 @@ pub fn transform_image(
content_type: "image/gif",
});
}
if animated {
ensure_animated_output_admissible(input, options, media_limits)?;
}
if should_transform_animated_webp_direct(sniffed, options, format) {
validate_dimensions_u32(media_limits, sniffed.width, sniffed.height)?;
let resolved = resolve_auto_quality(
@@ -574,8 +574,8 @@ async fn serve_image_transform(
requested_download: transform.requested_download,
});
}
let can_serve_original = transform.width.is_none()
&& transform.height.is_none()
let can_serve_original = ((transform.width.is_none() && transform.height.is_none())
|| options.is_animated())
&& is_browser_displayable_image(sniffed.mime);
let mut too_large_original = None;
let leader_original = &mut too_large_original;
@@ -325,3 +325,35 @@ fn a_cache_larger_than_the_pod_takes_at_most_half_of_it() {
fn the_budget_never_drops_below_its_floor() {
assert_eq!(128 << 20, native_memory_budget_bytes(64 << 20, 0));
}
#[tokio::test]
async fn an_animated_transform_over_the_work_budget_serves_the_original() {
let webp = crate::test_fixtures::ANIMATED_WEBP_1024_17_FRAMES;
let response = attachment_transform(
webp,
"anim.webp",
&[("format", "webp"), ("animated", "true"), ("width", "1000")],
)
.await;
assert_eq!(StatusCode::OK, response.status());
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
assert_eq!(webp, body.as_ref());
}
#[tokio::test]
async fn an_animated_transform_within_the_work_budget_is_transformed() {
let webp = crate::test_fixtures::ANIMATED_WEBP_1024_17_FRAMES;
let response = attachment_transform(
webp,
"anim.webp",
&[("format", "webp"), ("animated", "true"), ("width", "128")],
)
.await;
assert_eq!(StatusCode::OK, response.status());
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
assert_ne!(webp, body.as_ref());
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -32,6 +32,7 @@ pub fn apng_header(frames: u32) -> Vec<u8> {
pub const JXL_4100_LOSSY_ALPHA: &[u8] = include_bytes!("jxl_4100_lossy_alpha.jxl");
pub const JXL_64_LOSSY_ALPHA: &[u8] = include_bytes!("jxl_64_lossy_alpha.jxl");
pub const ANIMATED_WEBP_1024_17_FRAMES: &[u8] = include_bytes!("animated_webp_1024_17_frames.webp");
pub const JPEG_ROTATED_MISMATCHED_SCAN: &[u8] = include_bytes!("jpeg_rotated_mismatched_scan.jpg");
pub fn synthetic_png(width: u32, height: u32) -> Vec<u8> {
+5 -4
View File
@@ -10,10 +10,11 @@ pub use adversarial::{
};
pub use ffmpeg_cli::{ffmpeg_gen_media, ffmpeg_gen_mp4, ffmpeg_gen_rotated_mp4, ffmpeg_mirror_mp4};
pub use images::{
JPEG_ROTATED_MISMATCHED_SCAN, JXL_64_LOSSY_ALPHA, JXL_4100_LOSSY_ALPHA, animated_gif_fixture,
animated_gif_frames, apng_header, first_webp_anim_frame_size, gif_frame_delays_cs,
gif_loop_count, minimal_gif, png_dimensions, synthetic_bmp, synthetic_png,
webp_animation_loop_count, webp_canvas_size, webp_chunk_payloads, webp_with_metadata_chunk,
ANIMATED_WEBP_1024_17_FRAMES, JPEG_ROTATED_MISMATCHED_SCAN, JXL_64_LOSSY_ALPHA,
JXL_4100_LOSSY_ALPHA, animated_gif_fixture, animated_gif_frames, apng_header,
first_webp_anim_frame_size, gif_frame_delays_cs, gif_loop_count, minimal_gif, png_dimensions,
synthetic_bmp, synthetic_png, webp_animation_loop_count, webp_canvas_size, webp_chunk_payloads,
webp_with_metadata_chunk,
};
pub use media::{
fixture_audio_mp3_with_png_cover_art, fixture_audio_mp4_with_attached_picture,