From 032421634f6530f3751cc42e108541431506d798 Mon Sep 17 00:00:00 2001 From: Lilith River Date: Tue, 7 Apr 2026 05:35:22 -0600 Subject: [PATCH 1/4] chore: add fuzz dictionary for TIFF Format-specific dictionary with LE/BE byte order marks, TIFF magic number, and common IFD tag numbers as 2-byte LE and BE pairs. Also add coverage to fuzz/.gitignore. --- fuzz/.gitignore | 1 + fuzz/tiff.dict | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 fuzz/tiff.dict diff --git a/fuzz/.gitignore b/fuzz/.gitignore index a092511..1a45eee 100644 --- a/fuzz/.gitignore +++ b/fuzz/.gitignore @@ -1,3 +1,4 @@ target corpus artifacts +coverage diff --git a/fuzz/tiff.dict b/fuzz/tiff.dict new file mode 100644 index 0000000..3de14f5 --- /dev/null +++ b/fuzz/tiff.dict @@ -0,0 +1,38 @@ +# TIFF byte order and magic +"\x49\x49\x2A\x00" +"\x4D\x4D\x00\x2A" + +# Common IFD tags as 2-byte LE pairs +# 256 = ImageWidth +"\x00\x01" +# 257 = ImageLength +"\x01\x01" +# 258 = BitsPerSample +"\x02\x01" +# 259 = Compression +"\x03\x01" +# 262 = PhotometricInterpretation +"\x06\x01" +# 273 = StripOffsets +"\x11\x01" +# 277 = SamplesPerPixel +"\x15\x01" +# 278 = RowsPerStrip +"\x16\x01" +# 279 = StripByteCounts +"\x17\x01" +# 282 = XResolution +"\x1A\x01" +# 283 = YResolution +"\x1B\x01" + +# Common IFD tags as 2-byte BE pairs +"\x01\x00" +"\x01\x01" +"\x01\x02" +"\x01\x03" +"\x01\x06" +"\x01\x11" +"\x01\x15" +"\x01\x16" +"\x01\x17" From ea6e2a8d80b5c00b5edf117b6de6d767106b9378 Mon Sep 17 00:00:00 2001 From: Lilith River Date: Tue, 7 Apr 2026 08:41:16 -0600 Subject: [PATCH 2/4] feat: add decode_comprehensive fuzz target with safe metadata probing New fuzz target exercises TiffHeader::parse, dimensions/colortype probing, chunk-by-chunk reading, and multi-IFD iteration. Guards get_chunk_type/chunk_dimensions behind dimensions() success check to avoid documented panics on non-image IFDs. --- fuzz/Cargo.toml | 6 +++ fuzz/fuzz_targets/decode_comprehensive.rs | 50 +++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 fuzz/fuzz_targets/decode_comprehensive.rs diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index f971d29..90325f2 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -23,3 +23,9 @@ name = "decode_image" path = "fuzz_targets/decode_image.rs" test = false doc = false + +[[bin]] +name = "decode_comprehensive" +path = "fuzz_targets/decode_comprehensive.rs" +test = false +doc = false diff --git a/fuzz/fuzz_targets/decode_comprehensive.rs b/fuzz/fuzz_targets/decode_comprehensive.rs new file mode 100644 index 0000000..b0920bc --- /dev/null +++ b/fuzz/fuzz_targets/decode_comprehensive.rs @@ -0,0 +1,50 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + let cursor = std::io::Cursor::new(data); + + // Path 1: TiffHeader::parse (probe) + { + let _ = tiff::decoder::TiffHeader::parse(std::io::Cursor::new(data)); + } + + // Path 2: Full decode with strict limits + let mut decoder = match tiff::decoder::Decoder::open(cursor) { + Ok(d) => d, + Err(_) => return, + }; + + let mut limits = tiff::decoder::Limits::default(); + limits.decoding_buffer_size = 512 * 1024; + limits.ifd_value_size = 64 * 1024; + limits.intermediate_buffer_size = 512 * 1024; + + decoder = decoder.with_limits(limits); + + // Probe metadata — dimensions() triggers image validation, + // must succeed before get_chunk_type/chunk_dimensions (they unwrap image) + let dims_ok = decoder.dimensions().is_ok(); + let _ = decoder.colortype(); + if dims_ok { + let _ = decoder.get_chunk_type(); + let _ = decoder.chunk_dimensions(); + } + let _ = decoder.more_images(); + + // Read chunks individually if available + let _ = decoder.read_chunk(0); + + // Read full image + let _ = decoder.read_image(); + + // Iterate through all IFDs + while decoder.more_images() { + if decoder.next_image().is_err() { + break; + } + let _ = decoder.dimensions(); + let _ = decoder.colortype(); + let _ = decoder.read_image(); + } +}); From b0712855b1ff71d554bdaf4b963110f1d7fcbc92 Mon Sep 17 00:00:00 2001 From: Lilith River Date: Tue, 7 Apr 2026 18:36:19 -0600 Subject: [PATCH 3/4] ci: add nightly fuzz workflow (60s on push, 5min nightly) --- .github/workflows/fuzz.yml | 85 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 .github/workflows/fuzz.yml diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000..6116e42 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,85 @@ +name: Fuzz + +on: + push: + branches: [main] + schedule: + - cron: '0 3 * * *' + workflow_dispatch: + inputs: + duration: + description: 'Seconds per fuzz target' + required: false + default: '300' + +concurrency: + group: fuzz-${{ github.ref }} + cancel-in-progress: true + +jobs: + fuzz: + name: Fuzz (${{ matrix.target }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: + - decode_comprehensive + - decode_image + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@nightly + - uses: Swatinem/rust-cache@v2 + with: + key: fuzz-${{ matrix.target }} + cache-directories: fuzz/target + + - name: Install cargo-fuzz + run: cargo install cargo-fuzz --locked + + - name: Determine duration + id: duration + run: | + if [ "${{ github.event_name }}" = "schedule" ]; then + echo "seconds=300" >> "$GITHUB_OUTPUT" + elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "seconds=${{ github.event.inputs.duration || '300' }}" >> "$GITHUB_OUTPUT" + else + echo "seconds=60" >> "$GITHUB_OUTPUT" + fi + + - name: Seed corpus + run: | + mkdir -p fuzz/corpus/${{ matrix.target }} + cp fuzz/regression/* fuzz/corpus/${{ matrix.target }}/ 2>/dev/null || true + cp fuzz/seed_corpus/${{ matrix.target }}/* fuzz/corpus/${{ matrix.target }}/ 2>/dev/null || true + + - name: Fuzz ${{ matrix.target }} + run: | + cargo +nightly fuzz run ${{ matrix.target }} \ + -- -max_total_time=${{ steps.duration.outputs.seconds }} \ + -dict=fuzz/tiff.dict \ + -rss_limit_mb=2048 + + - name: Upload crashes + if: failure() + uses: actions/upload-artifact@v4 + with: + name: fuzz-crashes-${{ matrix.target }} + path: fuzz/artifacts/ + retention-days: 30 + + regression: + name: Fuzz regression + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Build + run: cargo build + - name: Regression seeds + run: | + if [ -d fuzz/regression ] && [ "$(ls fuzz/regression/ 2>/dev/null | wc -l)" -gt 0 ]; then + cargo test --test fuzz_regression 2>/dev/null || echo "No regression test found — seeds exist but no test harness" + fi From 26b18c51848df5297413141d6493ce8f9570928a Mon Sep 17 00:00:00 2001 From: Lilith River Date: Thu, 9 Apr 2026 04:46:05 -0600 Subject: [PATCH 4/4] fix: prevent shift overflow panic in ColorMap size validation `1usize << bits_per_sample` panics when bits_per_sample >= 64. Use checked_shl to return a LimitsExceeded error instead. Found by fuzzing decode_comprehensive with a BE TIFF containing PhotometricInterpretation=RGBPalette and BitsPerSample=83. --- src/decoder/image.rs | 5 +++-- .../oor_panic/colormap-shl-overflow.tiff | Bin 0 -> 1036 bytes 2 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 tests/fuzz_images/oor_panic/colormap-shl-overflow.tiff diff --git a/src/decoder/image.rs b/src/decoder/image.rs index 59c7521..1b51b51 100644 --- a/src/decoder/image.rs +++ b/src/decoder/image.rs @@ -454,8 +454,9 @@ impl Image { match tag_reader.find_tag(Tag::ColorMap)? { Some(v) => { let map = v.into_u16_vec()?; - let expected_len = 3usize - .checked_mul(1usize << bits_per_sample) + let expected_len = 1usize + .checked_shl(bits_per_sample as u32) + .and_then(|n| n.checked_mul(3)) .ok_or(TiffError::LimitsExceeded)?; if map.len() != expected_len { return Err(TiffError::FormatError( diff --git a/tests/fuzz_images/oor_panic/colormap-shl-overflow.tiff b/tests/fuzz_images/oor_panic/colormap-shl-overflow.tiff new file mode 100644 index 0000000000000000000000000000000000000000..d2905df3785817a0e0aab0bc18d298763a234dc5 GIT binary patch literal 1036 zcmebEWzb?^V76jmWn^&`6?6>+fyhwL&`<{#2SFD>K@cxgoPmXbfq@aI(11~jMKm-7 zq{=}=04N9qEDWL{jt)Sr#fj;MlXSCV-e$j8x{nno1JuEy#VEtT3}yp)j2cij6Oe7l z03;b4fohBx9l+u|Aj6;lsG7mOD6t?T6R4CItW*pr&BG`Na|{CqqY#qOqEPma|NsBJ z{Qu`aP#fcVAc5j|2AH#y7^UHCkP{(*8>pV36G7f$@Bm_=VoV(b