Skip to main content

wechat_media/
image.rs

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
15// JPEG segments can contain marker-shaped bytes, and scan data escapes them.
16// Walk the framing so an EOI inside metadata cannot disguise truncation. Bytes
17// after the actual EOI are permitted; received photos can include a trailer.
18fn 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
80// A ';' inside a palette, extension or compressed frame is not the trailer.
81// Follow block lengths; any bytes after the actual trailer stay in the source.
82fn 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; // Extension label; its contents use sub-blocks.
103            }
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; // LZW minimum code size, before data sub-blocks.
113            }
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            // The decoder can finish the pixels before consuming IEND. Require
139            // its zero-length chunk and fixed CRC without trimming the source.
140            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            // The RIFF boundary limits decoding, not the retained source bytes.
197            // https://developers.google.com/speed/webp/docs/riff_container
198            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        // A marker-shaped byte in a comment is not the stream's trailer.
365        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        // A truncated comment cannot be skipped just because it contains ';'.
372        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}