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

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


The following commit(s) were added to refs/heads/main by this push:
     new ae008896c GH-1317: bound FlightData frame field length before 
allocating (#1318)
ae008896c is described below

commit ae008896c5f959f9852c9ac28f75585d10055e5d
Author: Abdul Rawoof Khan <[email protected]>
AuthorDate: Tue Oct 6 13:20:37 2026 +0530

    GH-1317: bound FlightData frame field length before allocating (#1318)
    
    ## What's Changed
    
    `ArrowMessage.frame` is the DoPut/DoExchange request marshaller, and for
    each FlightData field it allocated a buffer sized by a length prefix
    taken straight off the wire before reading any content, so a client
    could force an arbitrarily large allocation with a tiny frame (an
    unauthenticated denial of service during request deframing). The
    descriptor and header paths used `new byte[size]`, which allocates on
    the JVM heap and bypasses the `BufferAllocator` limit entirely.
    Concretely, a ~10-byte HEADER frame whose length prefix encodes
    `0x7FFFFFFF` drove a `new byte[2147483647]` (2 GiB) heap allocation
    before the subsequent read failed.
    
    The length prefix is now read and validated in one place
    (`readFieldLength`): a negative length is rejected outright, and when
    the stream is `io.grpc.KnownLength` (gRPC's uncompressed
    `BufferInputStream`, where `available()` is the number of bytes left in
    the message) a length larger than that is rejected before anything is
    allocated. For other streams, such as the decompressing stream gRPC
    hands over for compressed messages, `available()` isn't the remaining
    length, so the read itself bounds the allocation: fields are read with
    `readNBytes` plus a length check, and the `ArrowBuf` is allocated once
    the bytes have actually arrived. The `KnownLength` path keeps the
    existing `GetReadableBuffer` fast path.
    
    `frame()` is also made exception-safe: `appMetadata` and `body` are
    released when a later field fails, and a repeated `app_metadata` field
    releases the earlier buffer like `BODY` already did, so a rejected frame
    can't leak direct memory.
    
    The new tests run each case over both a real gRPC `BufferInputStream`
    and a `GZIPInputStream`: a well-formed frame, oversized and negative
    lengths for all four fields (asserting the allocator's peak allocation
    stays at 0), a valid app_metadata and body followed by a rejected field
    (asserting nothing is left allocated), and a repeated field.
    
    Closes #1317.
---
 .../java/org/apache/arrow/flight/ArrowMessage.java |  96 ++++++++--
 .../org/apache/arrow/flight/TestArrowMessage.java  | 208 +++++++++++++++++++++
 2 files changed, 288 insertions(+), 16 deletions(-)

diff --git 
a/flight/flight-core/src/main/java/org/apache/arrow/flight/ArrowMessage.java 
b/flight/flight-core/src/main/java/org/apache/arrow/flight/ArrowMessage.java
index ab4eab304..d1c70031e 100644
--- a/flight/flight-core/src/main/java/org/apache/arrow/flight/ArrowMessage.java
+++ b/flight/flight-core/src/main/java/org/apache/arrow/flight/ArrowMessage.java
@@ -18,12 +18,12 @@ package org.apache.arrow.flight;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
-import com.google.common.io.ByteStreams;
 import com.google.protobuf.ByteString;
 import com.google.protobuf.CodedInputStream;
 import com.google.protobuf.CodedOutputStream;
 import com.google.protobuf.WireFormat;
 import io.grpc.Drainable;
+import io.grpc.KnownLength;
 import io.grpc.MethodDescriptor.Marshaller;
 import io.grpc.protobuf.ProtoUtils;
 import io.netty.buffer.ByteBuf;
@@ -281,11 +281,11 @@ class ArrowMessage implements AutoCloseable {
 
   private static ArrowMessage frame(BufferAllocator allocator, final 
InputStream stream) {
 
+    ArrowBuf body = null;
+    ArrowBuf appMetadata = null;
     try {
       FlightDescriptor descriptor = null;
       MessageMetadataResult header = null;
-      ArrowBuf body = null;
-      ArrowBuf appMetadata = null;
       while (stream.available() > 0) {
         final int tagFirstByte = stream.read();
         if (tagFirstByte == -1) {
@@ -295,25 +295,24 @@ class ArrowMessage implements AutoCloseable {
         switch (tag) {
           case DESCRIPTOR_TAG:
             {
-              int size = readRawVarint32(stream);
-              byte[] bytes = new byte[size];
-              ByteStreams.readFully(stream, bytes);
+              byte[] bytes = readFieldBytes(stream);
               descriptor = FlightDescriptor.parseFrom(bytes);
               break;
             }
           case HEADER_TAG:
             {
-              int size = readRawVarint32(stream);
-              byte[] bytes = new byte[size];
-              ByteStreams.readFully(stream, bytes);
-              header = MessageMetadataResult.create(ByteBuffer.wrap(bytes), 
size);
+              byte[] bytes = readFieldBytes(stream);
+              header = MessageMetadataResult.create(ByteBuffer.wrap(bytes), 
bytes.length);
               break;
             }
           case APP_METADATA_TAG:
             {
-              int size = readRawVarint32(stream);
-              appMetadata = allocator.buffer(size);
-              GetReadableBuffer.readIntoBuffer(stream, appMetadata, size, 
ENABLE_ZERO_COPY_READ);
+              if (appMetadata != null) {
+                // only read last app metadata.
+                appMetadata.close();
+                appMetadata = null;
+              }
+              appMetadata = readFieldBuffer(allocator, stream);
               break;
             }
           case BODY_TAG:
@@ -322,9 +321,7 @@ class ArrowMessage implements AutoCloseable {
               body.getReferenceManager().release();
               body = null;
             }
-            int size = readRawVarint32(stream);
-            body = allocator.buffer(size);
-            GetReadableBuffer.readIntoBuffer(stream, body, size, 
ENABLE_ZERO_COPY_READ);
+            body = readFieldBuffer(allocator, stream);
             break;
 
           default:
@@ -364,6 +361,8 @@ class ArrowMessage implements AutoCloseable {
       }
       return new ArrowMessage(descriptor, header, appMetadata, body);
     } catch (Exception ioe) {
+      // No ArrowMessage takes ownership of the buffers read so far, so 
release them here.
+      AutoCloseables.close(ioe, appMetadata, body);
       throw new RuntimeException(ioe);
     }
   }
@@ -377,6 +376,71 @@ class ArrowMessage implements AutoCloseable {
     return CodedInputStream.readRawVarint32(firstByte, is);
   }
 
+  /**
+   * Read and validate the length prefix of a length-delimited field.
+   *
+   * <p>The length is read straight off the wire, so it must not size an 
allocation unchecked. It
+   * can only be compared to the bytes left in the message when the stream is 
{@link KnownLength}:
+   * {@code available()} means something else on other streams (the 
decompressing stream gRPC uses
+   * for compressed messages reports 1 until EOF), so for those the read 
itself bounds the
+   * allocation, see {@link #readFieldBytes}.
+   */
+  private static int readFieldLength(InputStream stream) throws IOException {
+    final int size = readRawVarint32(stream);
+    if (size < 0) {
+      throw new IOException("Malformed FlightData frame: negative field length 
" + size);
+    }
+    if (stream instanceof KnownLength && size > stream.available()) {
+      throw fieldTooLong(size, stream.available());
+    }
+    return size;
+  }
+
+  /**
+   * Read a length-delimited field into a byte array.
+   *
+   * <p>{@link InputStream#readNBytes(int)} grows the array as bytes arrive, 
so a length prefix
+   * larger than the message is rejected without allocating the declared 
length first.
+   */
+  private static byte[] readFieldBytes(InputStream stream) throws IOException {
+    final int size = readFieldLength(stream);
+    final byte[] bytes = stream.readNBytes(size);
+    if (bytes.length != size) {
+      throw fieldTooLong(size, bytes.length);
+    }
+    return bytes;
+  }
+
+  /** Read a length-delimited field into a new buffer. The caller must release 
the buffer. */
+  private static ArrowBuf readFieldBuffer(BufferAllocator allocator, 
InputStream stream)
+      throws IOException {
+    if (!(stream instanceof KnownLength)) {
+      // The length can't be checked up front, so only allocate once the bytes 
have arrived.
+      final byte[] bytes = readFieldBytes(stream);
+      final ArrowBuf buf = allocator.buffer(bytes.length);
+      buf.writeBytes(bytes);
+      return buf;
+    }
+    final int size = readFieldLength(stream);
+    final ArrowBuf buf = allocator.buffer(size);
+    try {
+      GetReadableBuffer.readIntoBuffer(stream, buf, size, 
ENABLE_ZERO_COPY_READ);
+    } catch (IOException | RuntimeException e) {
+      buf.close();
+      throw e;
+    }
+    return buf;
+  }
+
+  private static IOException fieldTooLong(int size, int remaining) {
+    return new IOException(
+        "Malformed FlightData frame: field length "
+            + size
+            + " exceeds "
+            + remaining
+            + " bytes remaining in the message");
+  }
+
   /**
    * Convert the ArrowMessage to an InputStream.
    *
diff --git 
a/flight/flight-core/src/test/java/org/apache/arrow/flight/TestArrowMessage.java
 
b/flight/flight-core/src/test/java/org/apache/arrow/flight/TestArrowMessage.java
new file mode 100644
index 000000000..41e39ad2d
--- /dev/null
+++ 
b/flight/flight-core/src/test/java/org/apache/arrow/flight/TestArrowMessage.java
@@ -0,0 +1,208 @@
+/*
+ * 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.
+ */
+package org.apache.arrow.flight;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import com.google.common.collect.Iterables;
+import com.google.protobuf.ByteString;
+import com.google.protobuf.CodedOutputStream;
+import com.google.protobuf.WireFormat;
+import io.grpc.MethodDescriptor;
+import io.grpc.internal.ReadableBuffers;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Collections;
+import java.util.zip.GZIPInputStream;
+import java.util.zip.GZIPOutputStream;
+import org.apache.arrow.flight.impl.Flight.FlightData;
+import org.apache.arrow.memory.ArrowBuf;
+import org.apache.arrow.memory.BufferAllocator;
+import org.apache.arrow.memory.RootAllocator;
+import org.apache.arrow.vector.ipc.message.IpcOption;
+import org.apache.arrow.vector.ipc.message.MessageSerializer;
+import org.apache.arrow.vector.types.pojo.ArrowType;
+import org.apache.arrow.vector.types.pojo.Field;
+import org.apache.arrow.vector.types.pojo.Schema;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+/** Tests for deframing FlightData messages in {@link ArrowMessage}. */
+public class TestArrowMessage {
+
+  private static final int[] LENGTH_DELIMITED_FIELDS = {
+    FlightData.FLIGHT_DESCRIPTOR_FIELD_NUMBER,
+    FlightData.DATA_HEADER_FIELD_NUMBER,
+    FlightData.APP_METADATA_FIELD_NUMBER,
+    FlightData.DATA_BODY_FIELD_NUMBER
+  };
+
+  /** A declared field length far larger than any frame built here. */
+  private static final int OVERSIZED_LENGTH = 1 << 20;
+
+  private static final byte[] FIRST = new byte[] {1, 2, 3, 4};
+  private static final byte[] LAST = new byte[] {5, 6, 7, 8, 9, 10};
+
+  /** The kinds of stream gRPC hands to the marshaller. */
+  enum StreamType {
+    /** An uncompressed message: available() is the number of bytes left in 
the message. */
+    KNOWN_LENGTH {
+      @Override
+      InputStream open(byte[] frame) {
+        return ReadableBuffers.openStream(ReadableBuffers.wrap(frame), true);
+      }
+    },
+    /** A compressed message: available() is 1 until EOF, however long the 
message is. */
+    COMPRESSED {
+      @Override
+      InputStream open(byte[] frame) throws IOException {
+        final ByteArrayOutputStream compressed = new ByteArrayOutputStream();
+        try (GZIPOutputStream gzip = new GZIPOutputStream(compressed)) {
+          gzip.write(frame);
+        }
+        return new GZIPInputStream(new 
ByteArrayInputStream(compressed.toByteArray()));
+      }
+    };
+
+    abstract InputStream open(byte[] frame) throws IOException;
+  }
+
+  private BufferAllocator allocator;
+  private MethodDescriptor.Marshaller<ArrowMessage> marshaller;
+
+  @BeforeEach
+  public void setUp() {
+    allocator = new RootAllocator(Long.MAX_VALUE);
+    marshaller = ArrowMessage.createMarshaller(allocator);
+  }
+
+  @AfterEach
+  public void tearDown() {
+    // Fails if a test leaked a buffer.
+    allocator.close();
+  }
+
+  /** A well-formed frame parses whichever kind of stream it arrives on. */
+  @ParameterizedTest
+  @EnumSource
+  public void frameAcceptsWellFormedFrame(StreamType streamType) throws 
Exception {
+    final Schema schema =
+        new Schema(Collections.singletonList(Field.nullable("foo", new 
ArrowType.Int(32, true))));
+    final FlightData data =
+        FlightData.newBuilder()
+            .setFlightDescriptor(FlightDescriptor.command(FIRST).toProtocol())
+            .setDataHeader(
+                
ByteString.copyFrom(MessageSerializer.serializeMetadata(schema, 
IpcOption.DEFAULT)))
+            .setAppMetadata(ByteString.copyFrom(LAST))
+            .build();
+
+    try (ArrowMessage message = 
marshaller.parse(streamType.open(data.toByteArray()))) {
+      assertEquals(data.getFlightDescriptor(), message.getDescriptor());
+      assertEquals(schema, message.asSchema());
+      assertArrayEquals(LAST, toByteArray(message.getApplicationMetadata()));
+    }
+  }
+
+  /** A repeated buffer field keeps the last occurrence and releases the 
earlier one. */
+  @ParameterizedTest
+  @EnumSource
+  public void frameKeepsLastOfRepeatedField(StreamType streamType) throws 
Exception {
+    final ByteArrayOutputStream frame = new ByteArrayOutputStream();
+    for (byte[] value : new byte[][] {FIRST, LAST}) {
+      FlightData.newBuilder()
+          .setAppMetadata(ByteString.copyFrom(value))
+          .setDataBody(ByteString.copyFrom(value))
+          .build()
+          .writeTo(frame);
+    }
+
+    try (ArrowMessage message = 
marshaller.parse(streamType.open(frame.toByteArray()))) {
+      assertArrayEquals(LAST, toByteArray(message.getApplicationMetadata()));
+      assertArrayEquals(LAST, 
toByteArray(Iterables.getOnlyElement(message.getBufs())));
+    }
+    assertEquals(0, allocator.getAllocatedMemory());
+  }
+
+  /**
+   * A field declaring more bytes than the frame holds is rejected, without 
first allocating a
+   * buffer of the declared length.
+   */
+  @ParameterizedTest
+  @EnumSource
+  public void frameRejectsOversizedFieldLength(StreamType streamType) throws 
Exception {
+    for (int field : LENGTH_DELIMITED_FIELDS) {
+      assertRejected(streamType, fieldPrefix(field, OVERSIZED_LENGTH));
+    }
+    assertEquals(0, allocator.getPeakMemoryAllocation());
+  }
+
+  /** A negative length, which a 5-byte varint can encode, is rejected. */
+  @ParameterizedTest
+  @EnumSource
+  public void frameRejectsNegativeFieldLength(StreamType streamType) throws 
Exception {
+    for (int field : LENGTH_DELIMITED_FIELDS) {
+      assertRejected(streamType, fieldPrefix(field, -1));
+    }
+  }
+
+  /** Buffers read for earlier fields are released when a later field is 
rejected. */
+  @ParameterizedTest
+  @EnumSource
+  public void frameReleasesBuffersWhenLaterFieldIsRejected(StreamType 
streamType) throws Exception {
+    for (int field : LENGTH_DELIMITED_FIELDS) {
+      final ByteArrayOutputStream frame = new ByteArrayOutputStream();
+      FlightData.newBuilder()
+          .setAppMetadata(ByteString.copyFrom(FIRST))
+          .setDataBody(ByteString.copyFrom(LAST))
+          .build()
+          .writeTo(frame);
+      frame.write(fieldPrefix(field, OVERSIZED_LENGTH));
+
+      assertRejected(streamType, frame.toByteArray());
+      assertEquals(0, allocator.getAllocatedMemory());
+    }
+  }
+
+  private void assertRejected(StreamType streamType, byte[] frame) throws 
IOException {
+    final InputStream stream = streamType.open(frame);
+    final RuntimeException e = assertThrows(RuntimeException.class, () -> 
marshaller.parse(stream));
+    assertInstanceOf(IOException.class, e.getCause());
+  }
+
+  /** The tag and length prefix of a length-delimited field, without any 
content. */
+  private static byte[] fieldPrefix(int fieldNumber, int length) throws 
IOException {
+    final ByteArrayOutputStream frame = new ByteArrayOutputStream();
+    final CodedOutputStream out = CodedOutputStream.newInstance(frame);
+    out.writeTag(fieldNumber, WireFormat.WIRETYPE_LENGTH_DELIMITED);
+    out.writeUInt32NoTag(length);
+    out.flush();
+    return frame.toByteArray();
+  }
+
+  private static byte[] toByteArray(ArrowBuf buf) {
+    final byte[] bytes = new byte[(int) buf.readableBytes()];
+    buf.getBytes(0, bytes);
+    return bytes;
+  }
+}

Reply via email to