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 d8c1bf4d22 fix: bound IPC message allocations by the bytes actually 
read (#10522)
d8c1bf4d22 is described below

commit d8c1bf4d226b02e9dea06a9890e775d50ad5b627
Author: ranflarion <[email protected]>
AuthorDate: Fri Aug 7 09:59:24 2026 -0400

    fix: bound IPC message allocations by the bytes actually read (#10522)
    
    # Which issue does this PR close?
    
    - Closes #10521.
    
    # Rationale for this change
    
    `MessageReader::maybe_next` reserves both the metadata length and the
    message body length before reading any of the bytes they describe. Both
    come out of the stream itself, so a corrupted or truncated stream is
    handed straight to the allocator:
    `MutableBuffer::from_len_zeroed(message.bodyLength() as usize)` on an
    implausible length either aborts the process (`memory allocation of N
    bytes failed`, which is not catchable and takes the host process with
    it) or panics on `LayoutError`. A negative `bodyLength` is accepted too,
    since `as usize` wraps it to a large positive length.
    
    I hit this fuzzing real IPC blocks rather than crafted ones: single-bit
    flips over the framing region of genuine streams produced `memory
    allocation of 1125899907497992 bytes failed` and `SIGABRT`. Where those
    blocks cross disk or a network, one flipped bit ends the process instead
    of failing a read the caller could retry.
    
    # What changes are included in this PR?
    
    `bodyLength` now goes through `usize::try_from`, so a negative length is
    a parse error rather than a huge positive one.
    
    Neither length reserves more than `MAX_PREALLOC_BYTES` (64 MiB) before
    the bytes behind it have arrived. Bodies up to that size are allocated
    in one go exactly as before; larger ones grow as the data arrives, which
    costs the reallocations that `MutableBuffer::reserve` doubling implies.
    That constant is the one judgement call here, trading the size of the
    bounded allocation a malformed stream can still ask for against how
    large a body keeps the single-allocation path, so it is worth a second
    opinion.
    
    The metadata read switches from `resize(meta_len, 0)` plus `read_exact`
    to `take(meta_len).read_to_end(&mut self.buf)`. That reuses the retained
    capacity across messages and drops the zeroing entirely, so it should be
    slightly cheaper than what it replaces rather than a cost, and `Take`
    returns `Ok(0)` at its limit so there is no extra read.
    
    Only the streaming path is touched. `read_block` on the file side has
    the same shape at `arrow-ipc/src/reader.rs:875` and two `unwrap()`s on
    block metadata besides; I left it alone to keep this reviewable, and
    noted it in the issue.
    
    This overlaps #9777, which is after the same zeroing for performance
    reasons. The two want the same thing here, and I am happy to rebase onto
    whatever lands first.
    
    # Are these changes tested?
    
    Yes, two tests in `arrow-ipc/src/reader.rs`.
    `test_stream_reader_rejects_implausible_body_length` covers `i64::MAX`,
    `1 << 50` and `-1`;
    `test_stream_reader_rejects_unbacked_metadata_length` covers a metadata
    length of `i32::MAX` with nineteen bytes behind it. Both fail without
    the change: the first panics inside `MutableBuffer::from_len_zeroed`,
    and the second spends 7.7s zeroing 2 GiB before reporting the wrong
    error.
    
    The existing `arrow-ipc` suite passes (139 tests), along with `cargo fmt
    --all --check` and `cargo clippy -p arrow-ipc --all-targets
    --all-features -- -D warnings`.
    
    # Are there any user-facing changes?
    
    No API changes. A stream that previously aborted or panicked now returns
    an `ArrowError`. No breaking changes.
    
    ---------
    
    Co-authored-by: Jeffrey Vo <[email protected]>
---
 arrow-ipc/src/reader.rs | 130 ++++++++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 126 insertions(+), 4 deletions(-)

diff --git a/arrow-ipc/src/reader.rs b/arrow-ipc/src/reader.rs
index 39cdb731bb..d6aa2d6653 100644
--- a/arrow-ipc/src/reader.rs
+++ b/arrow-ipc/src/reader.rs
@@ -1796,6 +1796,38 @@ pub(crate) enum IpcMessage {
     },
 }
 
+/// Upper bound on how much memory a single message length is allowed to 
reserve before any
+/// of the bytes it promises have been read.
+///
+/// Message lengths come from the stream itself, so they cannot be trusted: a 
truncated or
+/// corrupted stream can declare a body of arbitrary size. Reserving that up 
front turns a
+/// malformed input into an allocation failure, which aborts the process 
rather than
+/// returning an error the caller can handle. Beyond this size the buffer 
grows as the data
+/// arrives instead, so an implausible length costs one bounded allocation and 
then fails as
+/// a short read.
+///
+/// The value trades the size of that bounded allocation against how large a 
body still gets
+/// read in a single allocation: bodies up to this size behave exactly as 
before, larger ones
+/// start here and the buffer doubles as the data arrives, so a body of `n` 
bytes costs
+/// `log2(n / MAX_PREALLOC_BYTES)` reallocations and the buffer never runs 
more than a factor
+/// of two ahead of the bytes actually received.
+const MAX_PREALLOC_BYTES: usize = 64 * 1024 * 1024;
+
+/// Reads exactly `len` bytes of message body, without reserving `len` before 
reading it.
+fn read_body_bounded<R: Read>(reader: &mut R, len: usize) -> 
Result<MutableBuffer, ArrowError> {
+    let mut buf = MutableBuffer::from_len_zeroed(len.min(MAX_PREALLOC_BYTES));
+    let mut filled = 0;
+    while filled < len {
+        let target = buf.len();
+        reader.read_exact(&mut buf.as_slice_mut()[filled..target])?;
+        filled = target;
+        if filled < len {
+            buf.resize(len.min(target.saturating_mul(2)), 0);
+        }
+    }
+    Ok(buf)
+}
+
 /// A low-level construct that reads [`Message::Message`]s from a reader while
 /// re-using a buffer for metadata. This is composed into [`StreamReader`].
 struct MessageReader<R> {
@@ -1827,15 +1859,29 @@ impl<R: Read> MessageReader<R> {
             return Ok(None);
         };
 
-        self.buf.resize(meta_len, 0);
-        self.reader.read_exact(&mut self.buf)?;
+        // `read_to_end` on a `Take` grows with the bytes that arrive, so an 
implausible
+        // `meta_len` costs a short read rather than the allocation it asks 
for.
+        self.buf.clear();
+        let read = (&mut self.reader)
+            .take(meta_len as u64)
+            .read_to_end(&mut self.buf)?;
+        if read != meta_len {
+            return Err(ArrowError::ParseError(format!(
+                "Unexpected end of stream: expected {meta_len} metadata bytes, 
got {read}"
+            )));
+        }
 
         let message = 
crate::root_as_message(self.buf.as_slice()).map_err(|err| {
             ArrowError::ParseError(format!("Unable to get root as message: 
{err:?}"))
         })?;
 
-        let mut buf = MutableBuffer::from_len_zeroed(message.bodyLength() as 
usize);
-        self.reader.read_exact(&mut buf)?;
+        let body_len = usize::try_from(message.bodyLength()).map_err(|_| {
+            ArrowError::ParseError(format!(
+                "Invalid IPC message body length: {}",
+                message.bodyLength()
+            ))
+        })?;
+        let buf = read_body_bounded(&mut self.reader, body_len)?;
 
         Ok(Some((message, buf)))
     }
@@ -3859,4 +3905,80 @@ mod tests {
             assert_eq!(read_batch.column(0).as_ref(), &values);
         }
     }
+
+    /// Builds a stream whose single message declares `body_length` but is 
followed by only
+    /// `body_bytes` bytes of body. The body length is consumed before the 
header is
+    /// interpreted, so the header itself is immaterial.
+    fn stream_with_declared_body(body_length: i64, body_bytes: usize) -> 
Vec<u8> {
+        let mut fbb = flatbuffers::FlatBufferBuilder::new();
+        let mut message = crate::MessageBuilder::new(&mut fbb);
+        message.add_version(crate::MetadataVersion::V5);
+        message.add_header_type(crate::MessageHeader::NONE);
+        message.add_bodyLength(body_length);
+        let root = message.finish();
+        fbb.finish(root, None);
+        let metadata = fbb.finished_data();
+
+        let mut stream = Vec::new();
+        stream.extend_from_slice(&CONTINUATION_MARKER);
+        stream.extend_from_slice(&(metadata.len() as i32).to_le_bytes());
+        stream.extend_from_slice(metadata);
+        stream.resize(stream.len() + body_bytes, 0xAB);
+        stream
+    }
+
+    /// A message body length is read from the stream before any of the bytes 
it describes,
+    /// so it cannot be trusted. Declaring an implausible one used to reserve 
it outright,
+    /// and the resulting allocation failure aborts the process instead of 
surfacing an error
+    /// the caller can handle.
+    #[test]
+    fn test_stream_reader_rejects_implausible_body_length() {
+        for body_length in [i64::MAX, 1 << 50, -1] {
+            let stream = stream_with_declared_body(body_length, 0);
+            let err = StreamReader::try_new(std::io::Cursor::new(stream), 
None).expect_err(
+                &format!("a message declaring {body_length} body bytes must 
not be accepted"),
+            );
+            let err = err.to_string();
+            assert!(
+                err.contains("Invalid IPC message body length")
+                    || err.contains("Unexpected end of stream")
+                    || err.contains("failed to fill whole buffer"),
+                "unexpected error for body_length {body_length}: {err}"
+            );
+        }
+    }
+
+    /// A plausible body length whose bytes end early must fail as a short 
read: before the
+    /// first read for a small body, and after at least one growth step for a 
body larger
+    /// than `MAX_PREALLOC_BYTES`.
+    #[test]
+    #[cfg_attr(miri, ignore)] // Takes too long
+    fn test_stream_reader_rejects_truncated_body() {
+        let over_prealloc = MAX_PREALLOC_BYTES as i64 + 1;
+        for (body_length, body_bytes) in [(1024, 10), (over_prealloc, 
MAX_PREALLOC_BYTES)] {
+            let stream = stream_with_declared_body(body_length, body_bytes);
+            let err = StreamReader::try_new(std::io::Cursor::new(stream), None)
+                .expect_err("a body backed by fewer bytes than declared must 
not be accepted");
+            assert!(
+                err.to_string().contains("failed to fill whole buffer"),
+                "unexpected error for body_length {body_length}: {err}"
+            );
+        }
+    }
+
+    /// The metadata length is untrusted for the same reason as the body 
length.
+    #[test]
+    fn test_stream_reader_rejects_unbacked_metadata_length() {
+        let mut stream = Vec::new();
+        stream.extend_from_slice(&CONTINUATION_MARKER);
+        stream.extend_from_slice(&i32::MAX.to_le_bytes());
+        stream.extend_from_slice(b"not this many bytes");
+
+        let err = StreamReader::try_new(std::io::Cursor::new(stream), None)
+            .expect_err("metadata length must be backed by the stream");
+        assert!(
+            err.to_string().contains("Unexpected end of stream"),
+            "unexpected error: {err}"
+        );
+    }
 }

Reply via email to