This is an automated email from the ASF dual-hosted git repository.

Jefffrey pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-rs.git


The following commit(s) were added to refs/heads/main by this push:
     new c1128062ff fix(arrow-avro): bound untrusted OCF block size and item 
counts (#10237)
c1128062ff is described below

commit c1128062ff2a5f80e60d1e5ae6beb098d853046b
Author: Han Damin <[email protected]>
AuthorDate: Thu Jul 9 22:42:10 2026 +0900

    fix(arrow-avro): bound untrusted OCF block size and item counts (#10237)
    
    # Which issue does this PR close?
    
    - Closes #10234
    - Closes #10235
    
    # Rationale for this change
    
    The Avro OCF reader trusts two length fields straight from the input, so
    a small crafted file can take down a process that parses untrusted Avro.
    A block size of `i64::MAX` reaches `Vec::reserve` before any payload is
    read and aborts the process on a huge allocation (#10234). A block count
    of `i64::MAX` spins the array/map item loop forever for a zero-byte item
    type like `null`, and `i64::MIN` overflows the negative-count negation
    (#10235).
    
    # What changes are included in this PR?
    
    - `block.rs`: reserve only what the current input buffer backs, and let
    the rest grow as data arrives.
    - `record.rs`: reject a block item count larger than the bytes left to
    decode, and take the negative-count magnitude with `unsigned_abs`.
    - `cursor.rs`: add `AvroCursor::remaining()`, used by that bound.
    
    A count past the remaining bytes can only describe items that are not
    there. Items that read input each need at least one byte, so only the
    zero-byte case is rejected and valid blocks keep working.
    
    # Are these changes tested?
    
    Yes. The new tests hang or abort before this change and pass after. In
    `reader::block`, an `i64::MAX` block size stays bounded instead of
    aborting, a negative size errors, and a well-formed block still
    round-trips. In `reader::record`, `i64::MAX` and `i64::MIN` block counts
    on an `array<null>` now error instead of spinning the item loop. `fmt
    --check` and `clippy` are clean.
    
    # Are there any user-facing changes?
    
    Malformed Avro OCF input that used to abort or hang now returns a clean
    `AvroError`. There are no public API changes.
    
    ---
    
    I'm Korean, so sorry if any wording reads a little awkward.
    
    ---------
    
    Co-authored-by: Jeffrey Vo <[email protected]>
---
 arrow-avro/src/reader/block.rs  | 77 ++++++++++++++++++++++++++++++++-
 arrow-avro/src/reader/record.rs | 95 ++++++++++++++++++++++++++++++++++++-----
 2 files changed, 161 insertions(+), 11 deletions(-)

diff --git a/arrow-avro/src/reader/block.rs b/arrow-avro/src/reader/block.rs
index e20818bf96..8df76c4b15 100644
--- a/arrow-avro/src/reader/block.rs
+++ b/arrow-avro/src/reader/block.rs
@@ -96,7 +96,13 @@ impl BlockDecoder {
                             AvroError::ParseError(format!("Block size cannot 
be negative, got {c}"))
                         })?;
 
-                        self.in_progress.data.reserve(self.bytes_remaining);
+                        // Only reserve what the current input backs: the 
block size is
+                        // input specified so could be an extreme value (e.g. 
i64::MAX)
+                        // in case of corrupted/malicious input. The rest is 
reserved
+                        // lazily by `extend_from_slice` below as data arrives.
+                        self.in_progress
+                            .data
+                            .reserve(self.bytes_remaining.min(buf.len()));
                         self.state = BlockDecoderState::Data;
                     }
                 }
@@ -148,3 +154,72 @@ impl BlockDecoder {
         self.bytes_remaining
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// Zig-zag encode `value` as an Avro `long` (variable-length integer).
+    fn encode_long(value: i64, out: &mut Vec<u8>) {
+        let mut n = ((value << 1) ^ (value >> 63)) as u64;
+        while n >= 0x80 {
+            out.push((n as u8) | 0x80);
+            n >>= 7;
+        }
+        out.push(n as u8);
+    }
+
+    #[test]
+    fn test_oversized_block_size_bounds_reserve() {
+        // A block advertising `i64::MAX` bytes must not reserve that up front 
when only
+        // a few payload bytes are present, or a crafted OCF aborts on a huge 
alloc (#10234).
+        let mut buf = Vec::new();
+        encode_long(1, &mut buf); // object count
+        encode_long(i64::MAX, &mut buf); // attacker-controlled block size
+        buf.extend_from_slice(&[0u8; 8]); // a handful of real bytes
+
+        let mut decoder = BlockDecoder::default();
+        let read = decoder.decode(&buf).unwrap();
+
+        assert_eq!(read, buf.len(), "all available input should be consumed");
+        assert!(
+            decoder.in_progress.data.capacity() <= buf.len(),
+            "capacity {} must stay bounded by available input {}, not the 
advertised i64::MAX",
+            decoder.in_progress.data.capacity(),
+            buf.len(),
+        );
+    }
+
+    #[test]
+    fn test_negative_block_size_errors() {
+        let mut buf = Vec::new();
+        encode_long(1, &mut buf); // object count
+        encode_long(-1, &mut buf); // invalid (negative) block size
+
+        let mut decoder = BlockDecoder::default();
+        let err = decoder.decode(&buf).unwrap_err();
+        assert!(
+            err.to_string().contains("Block size cannot be negative"),
+            "unexpected error: {err}",
+        );
+    }
+
+    #[test]
+    fn test_well_formed_block_round_trips() {
+        // The capped reserve must not change decoding of a normal block.
+        let payload = [1u8, 2, 3, 4];
+        let sync = [7u8; 16];
+        let mut buf = Vec::new();
+        encode_long(2, &mut buf); // object count
+        encode_long(payload.len() as i64, &mut buf); // block size
+        buf.extend_from_slice(&payload);
+        buf.extend_from_slice(&sync);
+
+        let mut decoder = BlockDecoder::default();
+        assert_eq!(decoder.decode(&buf).unwrap(), buf.len());
+        let block = decoder.flush().expect("a complete block");
+        assert_eq!(block.count, 2);
+        assert_eq!(block.data, payload);
+        assert_eq!(block.sync, sync);
+    }
+}
diff --git a/arrow-avro/src/reader/record.rs b/arrow-avro/src/reader/record.rs
index 306c777182..1d152870d7 100644
--- a/arrow-avro/src/reader/record.rs
+++ b/arrow-avro/src/reader/record.rs
@@ -2286,35 +2286,56 @@ fn process_blockwise(
         match block_count.cmp(&0) {
             Ordering::Equal => break,
             Ordering::Less => {
-                let count = (-block_count) as usize;
+                // `unsigned_abs` avoids overflowing `-block_count` for 
`i64::MIN` (#10235)
+                let count = block_count.unsigned_abs() as usize;
                 // A negative count is followed by a long of the size in bytes
-                let size_in_bytes = buf.get_long()? as usize;
+                let raw_size = buf.get_long()?;
+                let size_in_bytes = usize::try_from(raw_size).map_err(|_| {
+                    AvroError::ParseError(format!("Block size cannot be 
negative, got {raw_size}"))
+                })?;
                 match negative_behavior {
                     NegativeBlockBehavior::ProcessItems => {
                         // Process items one-by-one after reading size
-                        for _ in 0..count {
-                            on_item(buf)?;
-                        }
+                        total = process_block_items(buf, count, total, &mut 
on_item)?;
                     }
                     NegativeBlockBehavior::SkipBySize => {
                         // Skip the entire block payload at once
                         let _ = buf.get_fixed(size_in_bytes)?;
+                        total = total.saturating_add(count);
                     }
                 }
-                total += count;
             }
             Ordering::Greater => {
                 let count = block_count as usize;
-                for _ in 0..count {
-                    on_item(buf)?;
-                }
-                total += count;
+                total = process_block_items(buf, count, total, &mut on_item)?;
             }
         }
     }
     Ok(total)
 }
 
+/// Decode `count` items, capping the running total at `i32::MAX` (the largest 
index
+/// an Arrow list/map offset holds). Otherwise a crafted `i64::MAX` count of a 
zero-byte
+/// item like `null` spins the loop forever (#10235); byte-consuming items 
self-terminate
+/// on cursor exhaustion, so valid blocks (including `array<null>`) are 
unaffected.
+#[inline]
+fn process_block_items(
+    buf: &mut AvroCursor,
+    count: usize,
+    total: usize,
+    on_item: &mut impl FnMut(&mut AvroCursor) -> Result<(), AvroError>,
+) -> Result<usize, AvroError> {
+    let Some(new_total) = total.checked_add(count).filter(|&t| t <= i32::MAX 
as usize) else {
+        return Err(AvroError::ParseError(
+            "Capacity overflow when decoding array/map item 
blocks".to_string(),
+        ));
+    };
+    for _ in 0..count {
+        on_item(buf)?;
+    }
+    Ok(new_total)
+}
+
 #[inline]
 fn flush_values<T>(values: &mut Vec<T>) -> Vec<T> {
     std::mem::replace(values, Vec::with_capacity(DEFAULT_CAPACITY))
@@ -3434,6 +3455,60 @@ mod tests {
         assert_eq!(values.value(2), 3);
     }
 
+    /// Zig-zag + unsigned-LEB128 encode, correct for all `i64` including 
`MIN`/`MAX`
+    /// (`encode_avro_long` loops forever on those two values).
+    fn encode_avro_long_extreme(value: i64) -> Vec<u8> {
+        let mut n = ((value << 1) ^ (value >> 63)) as u64;
+        let mut out = Vec::new();
+        while n >= 0x80 {
+            out.push((n as u8) | 0x80);
+            n >>= 7;
+        }
+        out.push(n as u8);
+        out
+    }
+
+    // `array<null>` is the worst case: items consume no bytes, so an unbounded
+    // `block_count` spins the item loop without ever advancing the cursor 
(#10235).
+    fn array_of_null_decoder() -> Decoder {
+        let list_dt = 
avro_from_codec(Codec::List(Arc::new(avro_from_codec(Codec::Null))));
+        Decoder::try_new(&list_dt).unwrap()
+    }
+
+    #[test]
+    fn test_array_of_null_decodes() {
+        let mut decoder = array_of_null_decoder();
+        let mut data = encode_avro_long(3); // three null items
+        data.extend_from_slice(&encode_avro_long(0)); // empty-block terminator
+        decoder.decode(&mut AvroCursor::new(&data)).unwrap();
+    }
+
+    #[test]
+    fn test_array_block_count_i64_max_errors() {
+        // A positive `i64::MAX` block count must error rather than spin the 
item loop.
+        let mut decoder = array_of_null_decoder();
+        let mut data = encode_avro_long_extreme(i64::MAX); // item count
+        data.extend_from_slice(&encode_avro_long(0)); // empty-block terminator
+        let err = decoder.decode(&mut AvroCursor::new(&data)).unwrap_err();
+        assert!(
+            err.to_string().contains("Capacity overflow"),
+            "unexpected error: {err}",
+        );
+    }
+
+    #[test]
+    fn test_array_block_count_i64_min_errors() {
+        // `i64::MIN` previously overflowed `-block_count` before spinning the 
loop.
+        let mut decoder = array_of_null_decoder();
+        let mut data = encode_avro_long_extreme(i64::MIN); // negative item 
count
+        data.extend_from_slice(&encode_avro_long(0)); // block size in bytes
+        let err = decoder.decode(&mut AvroCursor::new(&data)).unwrap_err();
+        assert!(
+            err.to_string().contains("Capacity overflow"),
+            "unexpected error: {err}",
+        );
+    }
+
     #[test]
     fn test_nested_array_decoding() {
         let inner_ty = 
avro_from_codec(Codec::List(Arc::new(avro_from_codec(Codec::Int32))));

Reply via email to