From a7a08c438b1ca735d7afcae5260c4e96ebde84f8 Mon Sep 17 00:00:00 2001 From: kassoulet <1905+kassoulet@users.noreply.github.com> Date: Wed, 29 Apr 2026 20:10:20 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20Enforce=20c?= =?UTF-8?q?ompressed=20block=20size=20limit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change adds a 4MB limit on the size of compressed bzip2 blocks to protect against resource exhaustion attacks. - Added `MAX_COMPRESSED_BLOCK_SIZE` constant (4MB). - Introduced `CompressedBlockLimitExceeded` error variant. - Enforced limit in library's `decompress_block_into` and bz2zstd's worker loop. - Added regression test for the limit. Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- .jules/sentinel.md | 2 +- bz2zstd/src/main.rs | 16 +++++++++++- parallel_bzip2_decoder/src/error.rs | 16 ++++++++++++ parallel_bzip2_decoder/src/lib.rs | 39 +++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 2 deletions(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index b984ee9..82fe73e 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -1,4 +1,4 @@ ## 2025-05-15 - [Decompression Bomb and Path Collision Protection] **Vulnerability:** Resource exhaustion via decompression bombs and application crash (SIGBUS) due to self-overwriting memory-mapped files. **Learning:** Bzip2 blocks are typically 900KB but can be maliciously crafted. Memory-mapped files in Rust can trigger a SIGBUS if the underlying file is truncated (e.g., via `File::create`). -**Prevention:** Enforce a strict uncompressed size limit per block (2MB) using `Read::take`. Use `std::fs::canonicalize` to ensure input and output file paths are distinct before opening any output file. +**Prevention:** Enforce a strict uncompressed size limit per block (2MB) using `Read::take` and a compressed block size limit (4MB) to prevent OOM/resource exhaustion. Use `std::fs::canonicalize` to ensure input and output file paths are distinct before opening any output file. diff --git a/bz2zstd/src/main.rs b/bz2zstd/src/main.rs index 8ffda15..c7a4ed7 100644 --- a/bz2zstd/src/main.rs +++ b/bz2zstd/src/main.rs @@ -45,7 +45,9 @@ use std::path::PathBuf; use std::thread; mod writer; -use parallel_bzip2_decoder::{extract_bits, MarkerType, Scanner, MAX_BLOCK_SIZE}; +use parallel_bzip2_decoder::{ + extract_bits, MarkerType, Scanner, MAX_BLOCK_SIZE, MAX_COMPRESSED_BLOCK_SIZE, +}; use writer::OutputWriter; /// Command-line arguments for bz2zstd. @@ -309,6 +311,18 @@ fn main() -> Result<()> { // This avoids lock contention and repeated allocations || (Vec::new(), Compressor::new(args.zstd_level).unwrap()), |(decomp_buf, compressor), (idx, (start_bit, end_bit))| -> Result<()> { + // Security Check: Verify that the compressed block size is within limits. + // This protects against resource exhaustion from maliciously large compressed blocks. + let bit_len = end_bit.saturating_sub(start_bit); + let compressed_byte_len = ((bit_len + 7) / 8) as usize; + + if compressed_byte_len > MAX_COMPRESSED_BLOCK_SIZE { + return Err(anyhow::anyhow!( + "Compressed block limit exceeded ({} bytes) at block {}. Possible malicious file.", + MAX_COMPRESSED_BLOCK_SIZE, idx + )); + } + // Extract the compressed bzip2 block bits let mut block_data = Vec::new(); extract_bits(&mmap, start_bit, end_bit, &mut block_data); diff --git a/parallel_bzip2_decoder/src/error.rs b/parallel_bzip2_decoder/src/error.rs index 72c3a2e..b8cf7ef 100644 --- a/parallel_bzip2_decoder/src/error.rs +++ b/parallel_bzip2_decoder/src/error.rs @@ -29,6 +29,14 @@ pub enum Bz2Error { /// The limit that was exceeded limit: usize, }, + + /// Compressed block size limit exceeded. + CompressedBlockLimitExceeded { + /// Bit offset where the block starts + offset: u64, + /// The limit that was exceeded + limit: usize, + }, } impl fmt::Display for Bz2Error { @@ -51,6 +59,13 @@ impl fmt::Display for Bz2Error { limit, offset ) } + Bz2Error::CompressedBlockLimitExceeded { offset, limit } => { + write!( + f, + "Compressed block limit exceeded ({} bytes) at bit offset {}. Possible malicious file.", + limit, offset + ) + } } } } @@ -63,6 +78,7 @@ impl std::error::Error for Bz2Error { Bz2Error::MmapFailed(err) => Some(err), Bz2Error::InvalidFormat(_) => None, Bz2Error::DecompressionLimitExceeded { .. } => None, + Bz2Error::CompressedBlockLimitExceeded { .. } => None, } } } diff --git a/parallel_bzip2_decoder/src/lib.rs b/parallel_bzip2_decoder/src/lib.rs index 804fe88..0d3f93f 100644 --- a/parallel_bzip2_decoder/src/lib.rs +++ b/parallel_bzip2_decoder/src/lib.rs @@ -92,6 +92,10 @@ pub use scanner::{extract_bits, MarkerType, Scanner}; /// This protects against decompression bomb attacks. pub const MAX_BLOCK_SIZE: usize = 2 * 1024 * 1024; +/// Maximum allowed compressed size for a single bzip2 block (4MB). +/// This protects against resource exhaustion from maliciously large compressed blocks. +pub const MAX_COMPRESSED_BLOCK_SIZE: usize = 4 * 1024 * 1024; + use bzip2::read::BzDecoder; use crossbeam_channel::bounded; use std::collections::HashMap; @@ -290,6 +294,16 @@ pub fn decompress_block_into( out: &mut Vec, scratch: &mut Vec, ) -> Result<()> { + let bit_len = end_bit.saturating_sub(start_bit); + let compressed_byte_len = ((bit_len + 7) / 8) as usize; + + if compressed_byte_len > MAX_COMPRESSED_BLOCK_SIZE { + return Err(Bz2Error::CompressedBlockLimitExceeded { + offset: start_bit, + limit: MAX_COMPRESSED_BLOCK_SIZE, + }); + } + scratch.clear(); // Add minimal bzip2 header (BZh9 = highest compression level) scratch.extend_from_slice(b"BZh9"); @@ -375,3 +389,28 @@ pub fn decompress_file>(path: P) -> Result> { pub fn parallel_bzip2_cat>(path: P) -> Result> { decompress_file(path) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_compressed_block_limit() { + let data = vec![0u8; 1024]; + let mut out = Vec::new(); + let mut scratch = Vec::new(); + + // 5MB limit exceeded (MAX_COMPRESSED_BLOCK_SIZE is 4MB) + let start_bit = 0; + let end_bit = 5 * 1024 * 1024 * 8; + + let result = decompress_block_into(&data, start_bit, end_bit, &mut out, &mut scratch); + match result { + Err(Bz2Error::CompressedBlockLimitExceeded { offset, limit }) => { + assert_eq!(offset, 0); + assert_eq!(limit, MAX_COMPRESSED_BLOCK_SIZE); + } + _ => panic!("Expected CompressedBlockLimitExceeded error"), + } + } +}