mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 22:30:20 -06:00
ASCII PGM/PPM reserved a u32 (four bytes) per declared sample before reading any, against a Limits budget of one or two bytes per sample, so a 19-byte header could ask for 12 GiB. With Limits::none() a 2³¹ × 2³¹ header panics with "capacity overflow"; PBM reserved 2⁶² bytes before finding the raster missing and aborted. ASCII reservations are now capped by what the remaining bytes can hold (a PBM digit is at least a byte, a PGM/PPM sample at least a digit and a separator), and binary PBM checks the raster length before reserving. Limits::none() documents that it is still bounded by usize overflow checks, but the decoder multiplied w × h × channels, n × 2 (16-bit samples) and the PFM raster length unchecked. A new sample_count checks the product, and the length checks use checked_mul. Tests: a header alone reserves nothing (P1-P4), u32::MAX sizes are errors (P3, P5, P6, PF, P7), and ASCII files with no slack (PBM digits without separators, no trailing whitespace) still decode. The first two failed before the fix: an abort on the 2⁶² reservation, "capacity overflow" for the P2/P3 headers, and "attempt to multiply with overflow". Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
173 lines
6.1 KiB
Rust
173 lines
6.1 KiB
Rust
//! Decompression-bomb guards.
|
||
|
||
mod common;
|
||
use common::*;
|
||
use photocraft_codecs::*;
|
||
|
||
fn opts(l: Limits) -> DecodeOptions {
|
||
DecodeOptions { limits: l, ..Default::default() }
|
||
}
|
||
|
||
fn is_limit(r: Result<Image, CodecError>) -> bool {
|
||
matches!(r, Err(CodecError::LimitExceeded(_)))
|
||
}
|
||
|
||
fn encoded(f: Format) -> Vec<u8> {
|
||
let img = synth(64, 48, ChannelLayout::Rgba, SampleType::U8, 1, 0.1);
|
||
encode(&img, f, &EncodeOptions::default()).unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn max_pixels_enforced_for_every_format() {
|
||
for f in rw_formats() {
|
||
let b = encoded(f);
|
||
let l = Limits { max_pixels: 1000, ..Limits::default() };
|
||
assert!(is_limit(decode_with(&b, &opts(l))), "{f:?}");
|
||
let l = Limits { max_pixels: 64 * 48, ..Limits::default() };
|
||
assert!(decode_with(&b, &opts(l)).is_ok(), "{f:?} exact limit should pass");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn max_width_enforced_for_every_format() {
|
||
for f in rw_formats() {
|
||
let l = Limits { max_width: 63, ..Limits::default() };
|
||
assert!(is_limit(decode_with(&encoded(f), &opts(l))), "{f:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn max_height_enforced_for_every_format() {
|
||
for f in rw_formats() {
|
||
let l = Limits { max_height: 47, ..Limits::default() };
|
||
assert!(is_limit(decode_with(&encoded(f), &opts(l))), "{f:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn max_alloc_enforced_for_every_format() {
|
||
for f in rw_formats() {
|
||
let l = Limits { max_alloc: 64 * 48, ..Limits::default() };
|
||
assert!(decode_with(&encoded(f), &opts(l)).is_err(), "{f:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn limits_none_accepts() {
|
||
for f in rw_formats() {
|
||
assert!(decode_with(&encoded(f), &opts(Limits::none())).is_ok(), "{f:?}");
|
||
}
|
||
}
|
||
|
||
fn png_chunk(ty: &[u8; 4], data: &[u8]) -> Vec<u8> {
|
||
let mut v = (data.len() as u32).to_be_bytes().to_vec();
|
||
v.extend_from_slice(ty);
|
||
v.extend_from_slice(data);
|
||
let mut crc_in = ty.to_vec();
|
||
crc_in.extend_from_slice(data);
|
||
v.extend_from_slice(&crc32(&crc_in).to_be_bytes());
|
||
v
|
||
}
|
||
|
||
fn crc32(d: &[u8]) -> u32 {
|
||
let mut c = 0xFFFF_FFFFu32;
|
||
for &b in d {
|
||
c ^= b as u32;
|
||
for _ in 0..8 {
|
||
c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
|
||
}
|
||
}
|
||
!c
|
||
}
|
||
|
||
#[test]
|
||
fn png_bomb_header_rejected_before_alloc() {
|
||
let mut ihdr = Vec::new();
|
||
ihdr.extend_from_slice(&100_000u32.to_be_bytes());
|
||
ihdr.extend_from_slice(&100_000u32.to_be_bytes());
|
||
ihdr.extend_from_slice(&[16, 6, 0, 0, 0]);
|
||
let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
|
||
b.extend(png_chunk(b"IHDR", &ihdr));
|
||
b.extend(png_chunk(b"IDAT", &[0x78, 0x9C, 0x03, 0x00, 0x00, 0x00, 0x00, 0x01]));
|
||
b.extend(png_chunk(b"IEND", &[]));
|
||
assert!(is_limit(decode(&b)));
|
||
}
|
||
|
||
#[test]
|
||
fn pnm_bomb_header_rejected() {
|
||
assert!(is_limit(decode(b"P6\n200000 200000\n255\n\0\0\0")));
|
||
assert!(is_limit(decode(b"P7\nWIDTH 99999\nHEIGHT 99999\nDEPTH 4\nMAXVAL 65535\nTUPLTYPE RGB_ALPHA\nENDHDR\n")));
|
||
assert!(is_limit(decode(b"PF\n100000 100000\n-1.0\n")));
|
||
}
|
||
|
||
#[test]
|
||
fn pnm_header_alone_reserves_nothing_without_limits() {
|
||
// #1110: ASCII PGM/PPM reserved four bytes per declared sample before reading any. A
|
||
// 2³¹ × 2³¹ header asked for more than isize::MAX ("capacity overflow"), and PBM reserved
|
||
// 2⁶² bytes before finding the raster missing (an abort).
|
||
let none = opts(Limits::none());
|
||
let side = 1u32 << 31;
|
||
for f in [format!("P1\n{side} {side}\n"), format!("P2\n{side} {side}\n255\n"), format!("P3\n{side} {side}\n255\n"), format!("P4\n{side} {side}\n")] {
|
||
assert!(decode_with(f.as_bytes(), &none).is_err(), "{f:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn pnm_header_sizes_that_overflow_usize_are_errors_without_limits() {
|
||
let m = u32::MAX;
|
||
let none = opts(Limits::none());
|
||
for f in [
|
||
format!("P3\n{m} {m}\n255\n"),
|
||
format!("P6\n{m} {m}\n255\n"),
|
||
format!("P5\n{m} {m}\n65535\n"),
|
||
format!("PF\n{m} {m}\n-1.0\n"),
|
||
format!("P7\nWIDTH {m}\nHEIGHT {m}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n"),
|
||
] {
|
||
assert!(decode_with(f.as_bytes(), &none).is_err(), "{f:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn ascii_pnm_with_the_tightest_layout_still_decodes() {
|
||
// The capacity bound for #1110 must not cut off files with no slack: PBM digits need no
|
||
// separator, and the last ASCII sample needs no trailing whitespace.
|
||
assert_eq!(decode(b"P1\n3 1\n010").unwrap().data(), &[255, 0, 255]);
|
||
assert_eq!(decode(b"P2\n3 1\n255\n1 2 3").unwrap().data(), &[1, 2, 3]);
|
||
assert_eq!(decode(b"P3\n1 1\n255\n9 8 7").unwrap().data(), &[9, 8, 7]);
|
||
}
|
||
|
||
#[test]
|
||
fn jpeg_bomb_header_rejected() {
|
||
// SOI + SOF0 claiming 65535x65535x3 with no scan data.
|
||
let b = [0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x11, 8, 0xFF, 0xFF, 0xFF, 0xFF, 3, 1, 0x11, 0, 2, 0x11, 0, 3, 0x11, 0, 0xFF, 0xD9];
|
||
let l = Limits { max_pixels: 1 << 24, ..Limits::default() };
|
||
assert!(is_limit(decode_with(&b, &opts(l))));
|
||
}
|
||
|
||
#[test]
|
||
fn tiff_bomb_header_rejected() {
|
||
// Minimal little-endian TIFF claiming 1_000_000 x 1_000_000 8-bit gray.
|
||
let mut b = b"II*\0\x08\0\0\0".to_vec();
|
||
let entries: [(u16, u16, u32, u32); 6] = [(256, 4, 1, 1_000_000), (257, 4, 1, 1_000_000), (258, 3, 1, 8), (262, 3, 1, 1), (273, 4, 1, 0), (279, 4, 1, 0)];
|
||
b.extend_from_slice(&(entries.len() as u16).to_le_bytes());
|
||
for (tag, ty, count, val) in entries {
|
||
b.extend_from_slice(&tag.to_le_bytes());
|
||
b.extend_from_slice(&ty.to_le_bytes());
|
||
b.extend_from_slice(&count.to_le_bytes());
|
||
b.extend_from_slice(&val.to_le_bytes());
|
||
}
|
||
b.extend_from_slice(&0u32.to_le_bytes());
|
||
assert!(is_limit(decode(&b)));
|
||
}
|
||
|
||
#[test]
|
||
fn limits_check_api() {
|
||
let l = Limits::default();
|
||
assert!(l.check(100, 100, ChannelLayout::Rgba, SampleType::F32).is_ok());
|
||
assert!(matches!(l.check(0, 1, ChannelLayout::Gray, SampleType::U8), Err(CodecError::InvalidImage(_))));
|
||
assert!(matches!(l.check(u32::MAX, 1, ChannelLayout::Gray, SampleType::U8), Err(CodecError::LimitExceeded(_))));
|
||
let l = Limits { max_alloc: 399, ..Limits::default() };
|
||
assert!(l.check(10, 10, ChannelLayout::Rgba, SampleType::U8).is_err());
|
||
assert!(l.check(10, 10, ChannelLayout::Rgb, SampleType::U8).is_ok());
|
||
}
|