This is an automated email from the ASF dual-hosted git repository.
Kriskras99 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/avro-rs.git
The following commit(s) were added to refs/heads/main by this push:
new 661bbd9 fix: bound decompressed size to guard against decompression
bombs (#582)
661bbd9 is described below
commit 661bbd9df5e73126c9a52bb2e1698039a728a8d4
Author: Ismaël Mejía <[email protected]>
AuthorDate: Tue Jul 14 11:45:52 2026 +0200
fix: bound decompressed size to guard against decompression bombs (#582)
* fix: bound decompressed size to guard against decompression bombs
`Codec::decompress` grew the output buffer without limit, so a small
compressed
block could inflate to an enormous buffer and exhaust memory (a
"decompression
bomb"): deflate/zstd/bzip2/xz decompressed the whole stream into a `Vec`,
and
snappy allocated `vec![0; decompressed_size]` from the (untrusted) block
header.
Bound every codec's output by the configured `max_allocation_bytes`:
- deflate uses `decompress_to_vec_with_limit`, mapping the resulting
`HasMoreOutput` status to a memory-allocation error;
- snappy validates the header-declared size with `safe_len` before
allocating;
- zstd/bzip2/xz read through `Read::take(max + 1)` and reject an output
that
exceeds the budget.
The limit stays configurable through `max_allocation_bytes`, consistent
with the
existing byte-length guards. Legitimate blocks within the budget still
round-trip.
Assisted-by: GitHub Copilot:claude-opus-4.8
* refactor: address review on decompression bounds
- bzip2/xz: propagate a decode error instead of panicking. read_to_end can
fail on malformed/corrupt input, so replace unwrap_or_else(unreachable!)
with
dedicated Details::Bzip2Decompress / Details::XzDecompress errors, mapped
via
map_err, so a bad block returns an AvroResult error rather than aborting.
- decompression_bomb test: assert the failure is specifically
Details::MemoryAllocation, so a regression that fails for another reason
cannot mask the missing bound.
Assisted-by: GitHub Copilot:claude-opus-4.8
* refactor: gate new decompress error variants; fix stale deflate message
Addresses review:
- Feature-gate Details::Bzip2Decompress and Details::XzDecompress behind the
`bzip` and `xz` features (like the existing Snappy variants), so adding
them
is not a breaking change for downstream crates that exhaustively match on
Details without those features enabled.
- Update the deflate BadParam message: it no longer references
decompress_to_vec
("invalid output buffer size" / "not possible for _to_vec()") now that the
code uses decompress_to_vec_with_limit.
Assisted-by: GitHub Copilot:claude-opus-4.8
* fix: reject short Snappy blocks instead of panicking
A Snappy block ends with a 4-byte CRC32. Decompression sliced the block with
stream.len() - 4, which underflows (usize) and panics if a truncated or
corrupt
block is shorter than 4 bytes -- a decode failure turned into a process
abort.
Compute the data end with checked_sub(4) and return a new (snappy-gated)
Details::BadSnappyLength error for a too-short block, slicing via the
checked
offset. Adds a regression test over 0..4 byte inputs.
Assisted-by: GitHub Copilot:claude-opus-4.8
* chore: cleanup code and improve error message
---------
Co-authored-by: Kriskras99 <[email protected]>
---
avro/src/codec.rs | 95 ++++++++++++++++++++++++++++----------
avro/src/error.rs | 24 +++++++++-
avro/src/util.rs | 2 +-
avro/tests/decompression_bomb.rs | 98 ++++++++++++++++++++++++++++++++++++++++
4 files changed, 191 insertions(+), 28 deletions(-)
diff --git a/avro/src/codec.rs b/avro/src/codec.rs
index 31aa92c..661558e 100644
--- a/avro/src/codec.rs
+++ b/avro/src/codec.rs
@@ -16,6 +16,7 @@
// under the License.
//! Logic for all supported compression codecs in Avro.
+
use crate::{AvroResult, Error, error::Details, types::Value};
use strum::{EnumIter, EnumString, IntoStaticStr};
@@ -144,36 +145,50 @@ impl Codec {
/// Decompress a stream of bytes in-place.
pub fn decompress(self, stream: &mut Vec<u8>) -> AvroResult<()> {
+ // Cap the decompressed output at the configured allocation budget so a
+ // small compressed block cannot inflate to an enormous buffer (a
+ // "decompression bomb") and exhaust memory.
+ let max_bytes =
+
crate::util::max_allocation_bytes(crate::util::DEFAULT_MAX_ALLOCATION_BYTES);
*stream = match self {
Codec::Null => return Ok(()),
- Codec::Deflate(_settings) =>
miniz_oxide::inflate::decompress_to_vec(stream).map_err(|e| {
- let err = {
- use
miniz_oxide::inflate::TINFLStatus::{FailedCannotMakeProgress, BadParam,
Adler32Mismatch, Failed, Done, NeedsMoreInput, HasMoreOutput};
- use std::io::{Error,ErrorKind};
- match e.status {
- FailedCannotMakeProgress =>
Error::from(ErrorKind::UnexpectedEof),
- BadParam => Error::other("Unexpected error:
miniz_oxide reported invalid output buffer size. Please report this to avro-rs
developers."), // not possible for _to_vec()
- Adler32Mismatch => Error::from(ErrorKind::InvalidData),
- Failed => Error::from(ErrorKind::InvalidData),
- Done => Error::other("Unexpected error: miniz_oxide
reported an error with a success status. Please report this to avro-rs
developers."),
- NeedsMoreInput =>
Error::from(ErrorKind::UnexpectedEof),
- HasMoreOutput => Error::other("Unexpected error:
miniz_oxide has more data than the output buffer can hold. Please report this
to avro-rs developers."), // not possible for _to_vec()
- other => Error::other(format!("Unexpected error:
{other:?}"))
- }
+ Codec::Deflate(_settings) =>
miniz_oxide::inflate::decompress_to_vec_with_limit(stream,
max_bytes).map_err(|e| {
+ use std::io::ErrorKind;
+ use miniz_oxide::inflate::TINFLStatus;
+
+ let details = match e.status {
+ TINFLStatus::FailedCannotMakeProgress |
TINFLStatus::NeedsMoreInput =>
Details::DeflateDecompress(ErrorKind::UnexpectedEof.into()),
+ TINFLStatus::Adler32Mismatch | TINFLStatus::Failed |
TINFLStatus::BadParam =>
Details::DeflateDecompress(ErrorKind::InvalidData.into()),
+ TINFLStatus::Done =>
Details::DeflateDecompress(std::io::Error::other("Unexpected error: miniz_oxide
reported an error with a success status. Please report this to avro-rs
developers.")),
+ // Output is larger than max allocation allowed
+ TINFLStatus::HasMoreOutput => Details::MemoryAllocation {
+ desired: None,
+ maximum: max_bytes,
+ },
+ other =>
Details::DeflateDecompress(std::io::Error::other(format!("Unexpected error:
{other:?}")))
};
- Error::new(Details::DeflateDecompress(err))
+ Error::new(details)
})?,
#[cfg(feature = "snappy")]
Codec::Snappy => {
- let decompressed_size =
snap::raw::decompress_len(&stream[..stream.len() - 4])
+ // The block ends with a 4-byte CRC32; a truncated/corrupt
block
+ // shorter than that must error rather than underflow the
slice.
+ let data_end = stream
+ .len()
+ .checked_sub(4)
+ .ok_or(Details::BadSnappyLength(stream.len()))?;
+ let decompressed_size =
snap::raw::decompress_len(&stream[..data_end])
.map_err(Details::GetSnappyDecompressLen)?;
+ // The decompressed size is taken from the (untrusted) block
+ // header, so bound it before allocating for it.
+ let decompressed_size =
crate::util::safe_len(decompressed_size)?;
let mut decoded = vec![0; decompressed_size];
snap::raw::Decoder::new()
- .decompress(&stream[..stream.len() - 4], &mut decoded[..])
+ .decompress(&stream[..data_end], &mut decoded[..])
.map_err(Details::SnappyDecompress)?;
let mut last_four: [u8; 4] = [0; 4];
- last_four.copy_from_slice(&stream[(stream.len() - 4)..]);
+ last_four.copy_from_slice(&stream[data_end..]);
let expected: u32 = u32::from_be_bytes(last_four);
let mut hasher = crc32fast::Hasher::new();
@@ -187,14 +202,22 @@ impl Codec {
}
#[cfg(feature = "zstandard")]
Codec::Zstandard(_settings) => {
- use std::io::BufReader;
+ use std::io::{BufReader, Read};
use zstd::zstd_safe;
let mut decoded = Vec::new();
let buffer_size = zstd_safe::DCtx::in_size();
let buffer = BufReader::with_capacity(buffer_size,
&stream[..]);
- let mut decoder =
zstd::Decoder::new(buffer).map_err(Details::ZstdDecompress)?;
- std::io::copy(&mut decoder, &mut
decoded).map_err(Details::ZstdDecompress)?;
+ let decoder =
zstd::Decoder::new(buffer).map_err(Details::ZstdDecompress)?;
+ // Read one byte past the budget so an output that exactly
fills
+ // it is allowed, while a larger (bomb) output is detected.
+ decoder
+ .take((max_bytes as u64).saturating_add(1))
+ .read_to_end(&mut decoded)
+ .map_err(Details::ZstdDecompress)?;
+ if decoded.len() > max_bytes {
+ return Err(Details::MemoryAllocation { desired: None,
maximum: max_bytes }.into());
+ }
decoded
}
#[cfg(feature = "bzip")]
@@ -202,9 +225,14 @@ impl Codec {
use bzip2::read::BzDecoder;
use std::io::Read;
- let mut decoder = BzDecoder::new(&stream[..]);
let mut decoded = Vec::new();
- decoder.read_to_end(&mut decoded).unwrap_or_else(|_|
unreachable!("No I/O errors possible with Vec<u8>"));
+ BzDecoder::new(&stream[..])
+ .take((max_bytes as u64).saturating_add(1))
+ .read_to_end(&mut decoded)
+ .map_err(Details::Bzip2Decompress)?;
+ if decoded.len() > max_bytes {
+ return Err(Details::MemoryAllocation { desired: None,
maximum: max_bytes }.into());
+ }
decoded
}
#[cfg(feature = "xz")]
@@ -212,9 +240,14 @@ impl Codec {
use liblzma::read::XzDecoder;
use std::io::Read;
- let mut decoder = XzDecoder::new(&stream[..]);
let mut decoded: Vec<u8> = Vec::new();
- decoder.read_to_end(&mut decoded).unwrap_or_else(|_|
unreachable!("No I/O errors possible with Vec<u8>"));
+ XzDecoder::new(&stream[..])
+ .take((max_bytes as u64).saturating_add(1))
+ .read_to_end(&mut decoded)
+ .map_err(Details::XzDecompress)?;
+ if decoded.len() > max_bytes {
+ return Err(Details::MemoryAllocation { desired: None,
maximum: max_bytes }.into());
+ }
decoded
}
};
@@ -321,6 +354,18 @@ mod tests {
compress_and_decompress(Codec::Snappy)
}
+ #[cfg(feature = "snappy")]
+ #[test]
+ fn snappy_decompress_short_block_errors_without_panicking() {
+ // A block shorter than the trailing 4-byte CRC must return an error
+ // rather than underflowing `stream.len() - 4` and panicking.
+ for len in 0..4usize {
+ let mut stream = vec![0u8; len];
+ let result = Codec::Snappy.decompress(&mut stream);
+ assert!(result.is_err(), "len={len} should error, got {result:?}");
+ }
+ }
+
#[cfg(feature = "zstandard")]
#[test]
fn zstd_compress_and_decompress() -> TestResult {
diff --git a/avro/src/error.rs b/avro/src/error.rs
index bd9cbcb..2acb217 100644
--- a/avro/src/error.rs
+++ b/avro/src/error.rs
@@ -111,9 +111,17 @@ pub enum Details {
},
#[error(
- "Unable to allocate {desired} bytes (maximum allowed: {maximum}).
Change the limit using `apache_avro::util::max_allocation_bytes`"
+ "{} (maximum allowed: {maximum}). Change the limit using
`apache_avro::util::max_allocation_bytes`",
+ if let Some(desired) = desired {
+ format!("Unable to allocate {desired} bytes")
+ } else {
+ "Allocation limit reached with unknown amount of bytes
remaining".to_string()
+ },
)]
- MemoryAllocation { desired: usize, maximum: usize },
+ MemoryAllocation {
+ desired: Option<usize>,
+ maximum: usize,
+ },
/// Describe a specific error happening with decimal representation
#[error(
@@ -503,6 +511,10 @@ pub enum Details {
#[error("Failed to get snappy decompression length: {0}")]
GetSnappyDecompressLen(#[source] snap::Error),
+ #[cfg(feature = "snappy")]
+ #[error("Snappy-compressed block is {0} bytes, too short to contain the
trailing CRC32")]
+ BadSnappyLength(usize),
+
#[cfg(feature = "snappy")]
#[error("Failed to decompress with snappy: {0}")]
SnappyDecompress(#[source] snap::Error),
@@ -513,6 +525,14 @@ pub enum Details {
#[error("Failed to decompress with zstd: {0}")]
ZstdDecompress(#[source] std::io::Error),
+ #[cfg(feature = "bzip")]
+ #[error("Failed to decompress with bzip2: {0}")]
+ Bzip2Decompress(#[source] std::io::Error),
+
+ #[cfg(feature = "xz")]
+ #[error("Failed to decompress with xz: {0}")]
+ XzDecompress(#[source] std::io::Error),
+
#[error("Failed to read header: {0}")]
ReadHeader(#[source] std::io::Error),
diff --git a/avro/src/util.rs b/avro/src/util.rs
index 090d5c3..a0bcf29 100644
--- a/avro/src/util.rs
+++ b/avro/src/util.rs
@@ -179,7 +179,7 @@ pub(crate) fn safe_len(len: usize) -> AvroResult<usize> {
Ok(len)
} else {
Err(Details::MemoryAllocation {
- desired: len,
+ desired: Some(len),
maximum: max_bytes,
}
.into())
diff --git a/avro/tests/decompression_bomb.rs b/avro/tests/decompression_bomb.rs
new file mode 100644
index 0000000..b66197e
--- /dev/null
+++ b/avro/tests/decompression_bomb.rs
@@ -0,0 +1,98 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Decompression must be bounded by `max_allocation_bytes` so a small
+//! compressed block cannot inflate to an enormous buffer and exhaust memory.
+//!
+//! This is a dedicated integration test so it runs in its own process, where
+//! the (write-once) allocation limit can be set to a small value; the payloads
+//! are then only a few MiB, keeping the test cheap and deterministic.
+
+use apache_avro::Codec;
+use apache_avro::error::Details;
+
+const BUDGET: usize = 1024; // 1 KiB
+const BOMB_PLAINTEXT: usize = 8 * 1024; // 8 KiB, well over the budget
+
+fn set_budget() {
+ apache_avro::util::max_allocation_bytes(BUDGET);
+}
+
+/// Compresses `plaintext` bytes with `codec` and asserts the decompressed
+/// output (larger than the budget) is rejected rather than allocated, then
that
+/// a small payload still round-trips.
+fn assert_bounded(codec: Codec) {
+ set_budget();
+
+ let mut bomb = vec![0u8; BOMB_PLAINTEXT];
+ codec.compress(&mut bomb).expect("compress bomb");
+ assert!(
+ bomb.len() <= BUDGET,
+ "compressed bomb ({} bytes) should be far smaller than the budget",
+ bomb.len()
+ );
+ let result = codec.decompress(&mut bomb);
+ // Assert it fails *specifically* because the output exceeded the budget,
so
+ // a regression that fails decompression for another reason cannot pass.
+ match result {
+ Err(e) => assert!(
+ matches!(e.details(), Details::MemoryAllocation { .. }),
+ "expected a MemoryAllocation error, got {e:?}"
+ ),
+ Ok(()) => panic!("a decompressed output exceeding the budget must be
rejected"),
+ }
+
+ // A payload within the budget must still round-trip.
+ let original = b"apache avro decompression within budget".to_vec();
+ let mut data = original.clone();
+ codec.compress(&mut data).expect("compress small");
+ codec.decompress(&mut data).expect("decompress small");
+ assert_eq!(data, original);
+}
+
+#[test]
+fn deflate_decompression_is_bounded() {
+ use apache_avro::DeflateSettings;
+ assert_bounded(Codec::Deflate(DeflateSettings::default()));
+}
+
+#[test]
+#[cfg(feature = "snappy")]
+fn snappy_decompression_is_bounded() {
+ assert_bounded(Codec::Snappy);
+}
+
+#[test]
+#[cfg(feature = "zstandard")]
+fn zstandard_decompression_is_bounded() {
+ use apache_avro::ZstandardSettings;
+ assert_bounded(Codec::Zstandard(ZstandardSettings::default()));
+}
+
+#[test]
+#[cfg(feature = "bzip")]
+fn bzip2_decompression_is_bounded() {
+ use apache_avro::Bzip2Settings;
+ assert_bounded(Codec::Bzip2(Bzip2Settings::default()));
+}
+
+#[test]
+#[cfg(feature = "xz")]
+fn xz_decompression_is_bounded() {
+ use apache_avro::XzSettings;
+ assert_bounded(Codec::Xz(XzSettings::default()));
+}