1use anyhow::{ensure, Result};
2use image::{AnimationDecoder, ImageDecoder};
3use serde::{Deserialize, Serialize};
4use std::io::Cursor;
5
6#[derive(Clone, Serialize, Deserialize, Debug)]
7pub struct ImageInspection {
8 pub content_type: String,
9 pub width: u32,
10 pub height: u32,
11 pub frames: u32,
12 pub animation_preserved: bool,
13}
14
15fn jpeg_has_end_marker(bytes: &[u8]) -> bool {
19 if !bytes.starts_with(&[0xff, 0xd8]) {
20 return false;
21 }
22 let mut position = 2;
23 let mut in_scan = false;
24 let mut saw_scan = false;
25 while position < bytes.len() {
26 if in_scan {
27 while bytes.get(position).is_some_and(|b| *b != 0xff) {
28 position += 1;
29 }
30 }
31 if bytes.get(position) != Some(&0xff) {
32 return false;
33 }
34 while bytes.get(position) == Some(&0xff) {
35 position += 1;
36 }
37 let Some(&marker) = bytes.get(position) else {
38 return false;
39 };
40 position += 1;
41 match marker {
42 0x00 | 0xd0..=0xd7 if in_scan => continue,
43 0xd9 => return saw_scan,
44 0x00 | 0xd0..=0xd8 => return false,
45 0x01 => continue,
46 _ => {
47 let Some(length) = bytes.get(position..position + 2) else {
48 return false;
49 };
50 let length = u16::from_be_bytes([length[0], length[1]]) as usize;
51 if length < 2 || position + length > bytes.len() {
52 return false;
53 }
54 position += length;
55 if marker == 0xda {
56 saw_scan = true;
57 in_scan = true;
58 } else if marker != 0xdc {
59 in_scan = false;
60 }
61 }
62 }
63 }
64 false
65}
66
67fn gif_sub_blocks_end(bytes: &[u8], mut position: usize) -> Option<usize> {
68 loop {
69 let length = *bytes.get(position)? as usize;
70 position = position.checked_add(1 + length)?;
71 if position > bytes.len() {
72 return None;
73 }
74 if length == 0 {
75 return Some(position);
76 }
77 }
78}
79
80fn gif_has_trailer(bytes: &[u8]) -> bool {
83 if bytes.len() < 13 || ![b"GIF87a", b"GIF89a"].iter().any(|h| bytes.starts_with(*h)) {
84 return false;
85 }
86 let palette_size = |packed: u8| {
87 if packed & 0x80 != 0 {
88 3 * (2usize << (packed & 7))
89 } else {
90 0
91 }
92 };
93 let mut position = 13 + palette_size(bytes[10]);
94 while let Some(&block) = bytes.get(position) {
95 position += 1;
96 match block {
97 0x3b => return true,
98 0x21 => {
99 if bytes.get(position).is_none() {
100 return false;
101 }
102 position += 1; }
104 0x2c => {
105 let Some(descriptor) = bytes.get(position..position + 9) else {
106 return false;
107 };
108 position += 9 + palette_size(descriptor[8]);
109 if bytes.get(position).is_none() {
110 return false;
111 }
112 position += 1; }
114 _ => return false,
115 }
116 let Some(end) = gif_sub_blocks_end(bytes, position) else {
117 return false;
118 };
119 position = end;
120 }
121 false
122}
123
124fn png_has_end_chunk(bytes: &[u8]) -> bool {
125 if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
126 return false;
127 }
128 let mut position: usize = 8;
129 while let Some(header) = bytes.get(position..position + 8) {
130 let length = u32::from_be_bytes(header[..4].try_into().unwrap()) as usize;
131 let Some(end) = position.checked_add(12).and_then(|n| n.checked_add(length)) else {
132 return false;
133 };
134 if end > bytes.len() {
135 return false;
136 }
137 if &header[4..8] == b"IEND" {
138 return length == 0 && bytes[end - 4..end] == [0xae, 0x42, 0x60, 0x82];
141 }
142 position = end;
143 }
144 false
145}
146
147pub(crate) fn inspect_standard_image(bytes: &[u8]) -> Result<ImageInspection> {
148 ensure!(
149 !bytes.is_empty() && bytes.len() <= 16 * 1024 * 1024,
150 "image_size_limit"
151 );
152 let format =
153 image::guess_format(bytes).map_err(|_| anyhow::anyhow!("image_format_unsupported"))?;
154 let limits = || {
155 let mut l = image::Limits::default();
156 l.max_image_width = Some(4096);
157 l.max_image_height = Some(4096);
158 l.max_alloc = Some(64 * 1024 * 1024);
159 l
160 };
161 let mut frames = 0u32;
162 let mut total = 0u64;
163 let mut width = 0;
164 let mut height = 0;
165 let mut count = |frame: image::ImageResult<image::Frame>| -> Result<()> {
166 let f = frame.map_err(|_| anyhow::anyhow!("image_decode_failed"))?;
167 let (w, h) = f.buffer().dimensions();
168 ensure!(
169 w > 0 && h > 0 && w as u64 * h as u64 <= 4_000_000,
170 "image_decode_limit"
171 );
172 total += w as u64 * h as u64;
173 frames += 1;
174 ensure!(frames <= 500 && total <= 100_000_000, "image_decode_limit");
175 width = width.max(w);
176 height = height.max(h);
177 Ok(())
178 };
179 let mime = match format {
180 image::ImageFormat::Gif => {
181 ensure!(gif_has_trailer(bytes), "gif_trailer_missing");
182 let mut decoder = image::codecs::gif::GifDecoder::new(Cursor::new(bytes))?;
183 decoder.set_limits(limits())?;
184 for frame in decoder.into_frames() {
185 count(frame)?;
186 }
187 "image/gif"
188 }
189 image::ImageFormat::WebP => {
190 let encoded_length = bytes
191 .get(4..8)
192 .map(|b| u32::from_le_bytes(b.try_into().unwrap()) as u64 + 8)
193 .and_then(|n| usize::try_from(n).ok())
194 .filter(|n| *n >= 12 && *n <= bytes.len())
195 .ok_or_else(|| anyhow::anyhow!("webp_length_invalid"))?;
196 ensure!(encoded_length % 2 == 0, "webp_length_invalid");
199 let mut decoder =
200 image::codecs::webp::WebPDecoder::new(Cursor::new(&bytes[..encoded_length]))?;
201 decoder.set_limits(limits())?;
202 if decoder.has_animation() {
203 for frame in decoder.into_frames() {
204 count(frame)?;
205 }
206 } else {
207 let (w, h) = decoder.dimensions();
208 ensure!(
209 w > 0 && h > 0 && w as u64 * h as u64 <= 4_000_000,
210 "image_decode_limit"
211 );
212 image::DynamicImage::from_decoder(decoder)
213 .map_err(|_| anyhow::anyhow!("image_decode_failed"))?;
214 width = w;
215 height = h;
216 frames = 1;
217 }
218 "image/webp"
219 }
220 image::ImageFormat::Jpeg | image::ImageFormat::Png => {
221 if format == image::ImageFormat::Jpeg {
222 ensure!(jpeg_has_end_marker(bytes), "jpeg_trailer_missing");
223 } else {
224 ensure!(png_has_end_chunk(bytes), "png_trailer_missing");
225 }
226 let mut reader = image::ImageReader::with_format(Cursor::new(bytes), format);
227 reader.limits(limits());
228 let decoded = reader
229 .decode()
230 .map_err(|_| anyhow::anyhow!("image_decode_failed"))?;
231 width = decoded.width();
232 height = decoded.height();
233 ensure!(
234 width > 0 && height > 0 && width as u64 * height as u64 <= 16_777_216,
235 "image_decode_limit"
236 );
237 frames = 1;
238 if format == image::ImageFormat::Png {
239 "image/png"
240 } else {
241 "image/jpeg"
242 }
243 }
244 _ => anyhow::bail!("image_format_unsupported"),
245 };
246 ensure!(frames > 0, "image_no_frames");
247 Ok(ImageInspection {
248 content_type: mime.into(),
249 width,
250 height,
251 frames,
252 animation_preserved: frames > 1,
253 })
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259 use crate::inspect_image;
260 #[test]
261 fn png_is_fully_decoded_and_truncation_fails() {
262 let mut bytes = std::io::Cursor::new(vec![]);
263 image::DynamicImage::new_rgb8(8, 8)
264 .write_to(&mut bytes, image::ImageFormat::Png)
265 .unwrap();
266 let good = bytes.into_inner();
267 assert_eq!(inspect_image(&good).unwrap().frames, 1);
268 for length in [20, good.len() - 12, good.len() - 1] {
269 assert!(
270 inspect_image(&good[..length]).is_err(),
271 "accepted PNG truncated to {length}"
272 );
273 }
274 let mut missing_end = good[..good.len() - 12].to_vec();
275 missing_end.extend_from_slice(&12u32.to_be_bytes());
276 missing_end.extend_from_slice(b"tEXt");
277 missing_end.extend_from_slice(&good[good.len() - 12..]);
278 missing_end.extend_from_slice(&[0; 4]);
279 assert_eq!(
280 inspect_image(&missing_end).unwrap_err().to_string(),
281 "png_trailer_missing"
282 );
283 }
284
285 fn still_image(format: image::ImageFormat) -> Vec<u8> {
286 let mut out = Cursor::new(Vec::new());
287 image::DynamicImage::new_rgb8(8, 8)
288 .write_to(&mut out, format)
289 .unwrap();
290 out.into_inner()
291 }
292
293 #[test]
294 fn non_jpeg_images_with_appended_data_retain_source_bytes() {
295 for (format, mime) in [
296 (image::ImageFormat::Png, "image/png"),
297 (image::ImageFormat::Gif, "image/gif"),
298 (image::ImageFormat::WebP, "image/webp"),
299 ] {
300 let mut bytes = still_image(format);
301 bytes.extend_from_slice(b"synthetic-opaque-trailer!");
302 let original = bytes.clone();
303 let inspection = inspect_image(&bytes).unwrap_or_else(|e| panic!("{format:?}: {e}"));
304 assert_eq!(inspection.content_type, mime);
305 assert_eq!((inspection.width, inspection.height), (8, 8));
306 assert_eq!(bytes, original);
307 }
308 }
309
310 #[test]
311 fn webp_declared_boundary_cannot_use_bytes_outside_riff() {
312 let good = still_image(image::ImageFormat::WebP);
313 assert_eq!(inspect_image(&good).unwrap().content_type, "image/webp");
314 assert!(inspect_image(&good[..good.len() - 1]).is_err());
315 let mut short_boundary = good;
316 short_boundary[4..8].copy_from_slice(&4u32.to_le_bytes());
317 assert!(inspect_image(&short_boundary).is_err());
318 short_boundary[4..8].copy_from_slice(&u32::MAX.to_le_bytes());
319 assert!(inspect_image(&short_boundary).is_err());
320 }
321
322 #[test]
323 fn animated_webp_keeps_all_frames_with_appended_data() {
324 let mut bytes = include_bytes!("../tests/fixtures/two-frame.webp").to_vec();
325 let plain = inspect_image(&bytes).unwrap();
326 assert_eq!(plain.frames, 2);
327 assert!(plain.animation_preserved);
328 bytes.extend_from_slice(b"extra-source-data");
329 let original = bytes.clone();
330 let inspected = inspect_image(&bytes).unwrap();
331 assert_eq!(inspected.frames, 2);
332 assert_eq!((inspected.width, inspected.height), (12, 8));
333 assert!(inspected.animation_preserved);
334 assert_eq!(bytes, original);
335 }
336
337 #[test]
338 fn gif_animation_and_real_trailer_are_checked_separately() {
339 let mut bytes = Vec::new();
340 {
341 let mut encoder = image::codecs::gif::GifEncoder::new(&mut bytes);
342 encoder
343 .encode_frames([
344 image::Frame::new(image::RgbaImage::from_pixel(
345 8,
346 8,
347 image::Rgba([0, 0, 0, 255]),
348 )),
349 image::Frame::new(image::RgbaImage::from_pixel(
350 8,
351 8,
352 image::Rgba([255, 0, 0, 255]),
353 )),
354 ])
355 .unwrap();
356 }
357 let complete_length = bytes.len();
358 assert_eq!(bytes.last(), Some(&b';'));
359 bytes.extend_from_slice(b"opaque-appended-data");
360 let result = inspect_image(&bytes).unwrap();
361 assert_eq!(result.frames, 2);
362 assert!(result.animation_preserved);
363
364 let mut missing_trailer = bytes[..complete_length - 1].to_vec();
366 missing_trailer.extend_from_slice(&[0x21, 0xfe, 1, b';', 0]);
367 assert_eq!(
368 inspect_image(&missing_trailer).unwrap_err().to_string(),
369 "gif_trailer_missing"
370 );
371 missing_trailer.truncate(missing_trailer.len() - 1);
373 assert!(inspect_image(&missing_trailer).is_err());
374 }
375
376 fn jpeg() -> Vec<u8> {
377 let mut bytes = Cursor::new(vec![]);
378 image::DynamicImage::new_rgb8(8, 8)
379 .write_to(&mut bytes, image::ImageFormat::Jpeg)
380 .unwrap();
381 bytes.into_inner()
382 }
383
384 #[test]
385 fn jpeg_with_trailing_data_decodes_without_changing_source_bytes() {
386 let mut bytes = jpeg();
387 let checksum = <md5::Md5 as md5::Digest>::digest(&bytes);
388 bytes.extend_from_slice(&0x12345678u64.to_le_bytes());
389 bytes.extend_from_slice(&checksum);
390 let original = bytes.clone();
391 let inspected = inspect_image(&bytes).unwrap();
392 assert_eq!(inspected.content_type, "image/jpeg");
393 assert_eq!(
394 (inspected.width, inspected.height, inspected.frames),
395 (8, 8, 1)
396 );
397 assert_eq!(bytes, original);
398 }
399
400 #[test]
401 fn progressive_jpeg_multiple_scans_allow_trailing_data() {
402 let mut bytes = include_bytes!("../tests/fixtures/progressive.jpg").to_vec();
403 bytes.extend_from_slice(b"synthetic-photo-trailer!");
404 let inspected = inspect_image(&bytes).unwrap();
405 assert_eq!((inspected.width, inspected.height), (16, 12));
406 assert_eq!(inspected.content_type, "image/jpeg");
407 let end = bytes.len() - 24 - 2;
408 assert!(!jpeg_has_end_marker(&bytes[..end]));
409 }
410
411 #[test]
412 fn jpeg_end_marker_inside_metadata_does_not_hide_truncation() {
413 let image = jpeg();
414 let mut bytes = vec![0xff, 0xd8, 0xff, 0xfe, 0, 6, 0x11, 0xff, 0xd9, 0x22];
415 bytes.extend_from_slice(&image[2..]);
416 assert!(inspect_image(&bytes).is_ok());
417 bytes.truncate(bytes.len() - 2);
418 assert_eq!(
419 inspect_image(&bytes).unwrap_err().to_string(),
420 "jpeg_trailer_missing"
421 );
422 bytes[4..6].copy_from_slice(&u16::MAX.to_be_bytes());
423 bytes.extend_from_slice(&[0xff, 0xd9]);
424 assert!(!jpeg_has_end_marker(&bytes));
425 }
426
427 #[test]
428 fn jpeg_scan_stuffing_and_restart_markers_are_not_end_markers() {
429 let bytes = [
430 0xff, 0xd8, 0xff, 0xda, 0, 2, 7, 0xff, 0, 0xd9, 0xff, 0xd0, 9, 0xff,
431 ];
432 assert!(!jpeg_has_end_marker(&bytes));
433 let mut complete = bytes.to_vec();
434 complete.push(0xd9);
435 assert!(jpeg_has_end_marker(&complete));
436 }
437}