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

iemejia pushed a commit to branch branch-1.11
in repository https://gitbox.apache.org/repos/asf/avro.git


The following commit(s) were added to refs/heads/branch-1.11 by this push:
     new 5242f3fbff AVRO-4247: [Java] Enforce decompression size limits (#3745)
5242f3fbff is described below

commit 5242f3fbff4d6ebd31b1188869e058f09f48a9a1
Author: Ismaël Mejía <[email protected]>
AuthorDate: Thu May 14 14:51:01 2026 +0200

    AVRO-4247: [Java] Enforce decompression size limits (#3745)
    
    Cherry-pick of d28279dace from master with Java 8 compatibility fixes:
    - Replace Objects.checkFromIndexSize() (Java 9+) with manual bounds check
    - Replace ByteArrayOutputStream.writeBytes() override (Java 11+) with
      standalone method delegating to write(b, 0, b.length)
---
 .../java/org/apache/avro/SystemLimitException.java |  79 ++++++++++++++++
 .../main/java/org/apache/avro/file/BZip2Codec.java |   4 +-
 .../java/org/apache/avro/file/DeflateCodec.java    |   4 +-
 .../java/org/apache/avro/file/SnappyCodec.java     |   6 +-
 .../main/java/org/apache/avro/file/XZCodec.java    |   3 +-
 .../java/org/apache/avro/file/ZstandardCodec.java  |   3 +-
 .../avro/util/NonCopyingByteArrayOutputStream.java | 102 ++++++++++++++++++++-
 .../util/NonCopyingByteArrayOutputStreamTest.java  |  93 +++++++++++++++++++
 8 files changed, 286 insertions(+), 8 deletions(-)

diff --git 
a/lang/java/avro/src/main/java/org/apache/avro/SystemLimitException.java 
b/lang/java/avro/src/main/java/org/apache/avro/SystemLimitException.java
index a96f812d84..e3c86e05b1 100644
--- a/lang/java/avro/src/main/java/org/apache/avro/SystemLimitException.java
+++ b/lang/java/avro/src/main/java/org/apache/avro/SystemLimitException.java
@@ -18,6 +18,7 @@
 
 package org.apache.avro;
 
+import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 /**
@@ -58,6 +59,30 @@ public class SystemLimitException extends 
AvroRuntimeException {
   private static int maxCollectionLength = MAX_ARRAY_VM_LIMIT;
   private static int maxStringLength = MAX_ARRAY_VM_LIMIT;
 
+  private static final Logger LOG = 
LoggerFactory.getLogger(SystemLimitException.class);
+
+  /**
+   * System property declaring max size of any decompression stream: {@value}.
+   */
+  public static final String MAX_DECOMPRESS_LENGTH_PROPERTY = 
"org.apache.avro.limits.decompress.maxLength";
+
+  /**
+   * Default limit when it is lower than the heap-aware limit: {@value}.
+   */
+  private static final long DEFAULT_MAX_DECOMPRESS_LENGTH = 200L * 1024 * 1024;
+
+  /**
+   * Keep the default decompression limit below the maximum heap to avoid 
allowing
+   * a single block to exhaust constrained JVMs: {@value}.
+   */
+  private static final long DEFAULT_MAX_DECOMPRESS_HEAP_FRACTION = 4;
+
+  /**
+   * Calculated max decompress length.
+   */
+  public static final long MAX_DECOMPRESS_LENGTH = 
getLongLimitFromProperty(MAX_DECOMPRESS_LENGTH_PROPERTY,
+      defaultMaxDecompressLength());
+
   static {
     resetLimits();
   }
@@ -89,6 +114,45 @@ public class SystemLimitException extends 
AvroRuntimeException {
     return i;
   }
 
+  /**
+   * Get a long value stored in a system property, used to configure the system
+   * behaviour of output.
+   *
+   * @param property     The system property to fetch
+   * @param defaultValue The value to use if the system property is not 
present or
+   *                     parsable as a long
+   * @return The value from the system property
+   */
+  private static long getLongLimitFromProperty(String property, long 
defaultValue) {
+    String prop = System.getProperty(property);
+    long limit = defaultValue;
+    if (prop != null) {
+      try {
+        long parsed = Long.parseLong(prop);
+        if (parsed <= 0) {
+          LOG.warn("Invalid value '{}' for property '{}': must be positive. 
Using default: {}", prop, property,
+              defaultValue);
+        } else {
+          limit = parsed;
+        }
+      } catch (NumberFormatException e) {
+        LOG.warn("Could not parse property '{}' value '{}'. Using default: 
{}", property, prop, defaultValue);
+      }
+    }
+    return limit;
+  }
+
+  /**
+   * Calculate a max decompression length as a fraction of the maximum memory 
of
+   * the runtime.
+   * 
+   * @return the calculated max default decompression length.
+   */
+  private static long defaultMaxDecompressLength() {
+    return Math.min(DEFAULT_MAX_DECOMPRESS_LENGTH,
+        Math.max(1L, Runtime.getRuntime().maxMemory() / 
DEFAULT_MAX_DECOMPRESS_HEAP_FRACTION));
+  }
+
   /**
    * Check to ensure that reading the bytes is within the specified limits.
    *
@@ -180,6 +244,21 @@ public class SystemLimitException extends 
AvroRuntimeException {
     return (int) length;
   }
 
+  /**
+   * Check there is capacity to write data to a stream.
+   *
+   * @param limit        total capacity limit
+   * @param streamLength current stream size
+   * @param bytes        bytes to add to the stream
+   * @throws SystemLimitException if the limit is exceeded.
+   */
+  public static void checkMaxDecompressCapacity(long limit, long streamLength, 
int bytes) {
+    if (streamLength + bytes > limit) {
+      throw new SystemLimitException(
+          String.format("Buffer size %,d (bytes) exceeds maximum allowed size 
%,d.", (streamLength + bytes), limit));
+    }
+  }
+
   /** Reread the limits from the system properties. */
   // VisibleForTesting
   static void resetLimits() {
diff --git a/lang/java/avro/src/main/java/org/apache/avro/file/BZip2Codec.java 
b/lang/java/avro/src/main/java/org/apache/avro/file/BZip2Codec.java
index 8fd6b6a09b..dcdc859068 100644
--- a/lang/java/avro/src/main/java/org/apache/avro/file/BZip2Codec.java
+++ b/lang/java/avro/src/main/java/org/apache/avro/file/BZip2Codec.java
@@ -25,6 +25,8 @@ import org.apache.avro.util.NonCopyingByteArrayOutputStream;
 import 
org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream;
 import 
org.apache.commons.compress.compressors.bzip2.BZip2CompressorOutputStream;
 
+import static 
org.apache.avro.util.NonCopyingByteArrayOutputStream.capacityLimitedOutputStream;
+
 /** * Implements bzip2 compression and decompression. */
 public class BZip2Codec extends Codec {
 
@@ -60,7 +62,7 @@ public class BZip2Codec extends Codec {
         compressedData.remaining());
 
     @SuppressWarnings("resource")
-    NonCopyingByteArrayOutputStream baos = new 
NonCopyingByteArrayOutputStream(DEFAULT_BUFFER_SIZE);
+    NonCopyingByteArrayOutputStream baos = 
capacityLimitedOutputStream(DEFAULT_BUFFER_SIZE);
 
     try (BZip2CompressorInputStream inputStream = new 
BZip2CompressorInputStream(bais)) {
 
diff --git 
a/lang/java/avro/src/main/java/org/apache/avro/file/DeflateCodec.java 
b/lang/java/avro/src/main/java/org/apache/avro/file/DeflateCodec.java
index e6d58e46a1..56e3bc929d 100644
--- a/lang/java/avro/src/main/java/org/apache/avro/file/DeflateCodec.java
+++ b/lang/java/avro/src/main/java/org/apache/avro/file/DeflateCodec.java
@@ -27,6 +27,8 @@ import java.util.zip.InflaterOutputStream;
 
 import org.apache.avro.util.NonCopyingByteArrayOutputStream;
 
+import static 
org.apache.avro.util.NonCopyingByteArrayOutputStream.capacityLimitedOutputStream;
+
 /**
  * Implements DEFLATE (RFC1951) compression and decompression.
  *
@@ -78,7 +80,7 @@ public class DeflateCodec extends Codec {
 
   @Override
   public ByteBuffer decompress(ByteBuffer data) throws IOException {
-    NonCopyingByteArrayOutputStream baos = new 
NonCopyingByteArrayOutputStream(DEFAULT_BUFFER_SIZE);
+    NonCopyingByteArrayOutputStream baos = 
capacityLimitedOutputStream(DEFAULT_BUFFER_SIZE);
     try (OutputStream outputStream = new InflaterOutputStream(baos, 
getInflater())) {
       outputStream.write(data.array(), computeOffset(data), data.remaining());
     }
diff --git a/lang/java/avro/src/main/java/org/apache/avro/file/SnappyCodec.java 
b/lang/java/avro/src/main/java/org/apache/avro/file/SnappyCodec.java
index 454d2925de..95886013d6 100644
--- a/lang/java/avro/src/main/java/org/apache/avro/file/SnappyCodec.java
+++ b/lang/java/avro/src/main/java/org/apache/avro/file/SnappyCodec.java
@@ -24,6 +24,8 @@ import java.util.zip.CRC32;
 
 import org.xerial.snappy.Snappy;
 
+import org.apache.avro.SystemLimitException;
+
 /** * Implements Snappy compression and decompression. */
 public class SnappyCodec extends Codec {
   private final CRC32 crc32 = new CRC32();
@@ -66,7 +68,9 @@ public class SnappyCodec extends Codec {
   @Override
   public ByteBuffer decompress(ByteBuffer in) throws IOException {
     int offset = computeOffset(in);
-    ByteBuffer out = ByteBuffer.allocate(Snappy.uncompressedLength(in.array(), 
offset, in.remaining() - 4));
+    final int uncompressedLength = Snappy.uncompressedLength(in.array(), 
offset, in.remaining() - 4);
+    
SystemLimitException.checkMaxDecompressCapacity(SystemLimitException.MAX_DECOMPRESS_LENGTH,
 0, uncompressedLength);
+    ByteBuffer out = ByteBuffer.allocate(uncompressedLength);
     int size = Snappy.uncompress(in.array(), offset, in.remaining() - 4, 
out.array(), 0);
     ((Buffer) out).limit(size);
 
diff --git a/lang/java/avro/src/main/java/org/apache/avro/file/XZCodec.java 
b/lang/java/avro/src/main/java/org/apache/avro/file/XZCodec.java
index bc674b7346..74ffaec302 100644
--- a/lang/java/avro/src/main/java/org/apache/avro/file/XZCodec.java
+++ b/lang/java/avro/src/main/java/org/apache/avro/file/XZCodec.java
@@ -67,7 +67,8 @@ public class XZCodec extends Codec {
 
   @Override
   public ByteBuffer decompress(ByteBuffer data) throws IOException {
-    NonCopyingByteArrayOutputStream baos = new 
NonCopyingByteArrayOutputStream(DEFAULT_BUFFER_SIZE);
+    NonCopyingByteArrayOutputStream baos = NonCopyingByteArrayOutputStream
+        .capacityLimitedOutputStream(DEFAULT_BUFFER_SIZE);
     InputStream bytesIn = new ByteArrayInputStream(data.array(), 
computeOffset(data), data.remaining());
 
     try (InputStream ios = new XZCompressorInputStream(bytesIn)) {
diff --git 
a/lang/java/avro/src/main/java/org/apache/avro/file/ZstandardCodec.java 
b/lang/java/avro/src/main/java/org/apache/avro/file/ZstandardCodec.java
index 0d4e31958d..ddd8dd6dff 100644
--- a/lang/java/avro/src/main/java/org/apache/avro/file/ZstandardCodec.java
+++ b/lang/java/avro/src/main/java/org/apache/avro/file/ZstandardCodec.java
@@ -77,7 +77,8 @@ public class ZstandardCodec extends Codec {
 
   @Override
   public ByteBuffer decompress(ByteBuffer compressedData) throws IOException {
-    NonCopyingByteArrayOutputStream baos = new 
NonCopyingByteArrayOutputStream(DEFAULT_BUFFER_SIZE);
+    NonCopyingByteArrayOutputStream baos = NonCopyingByteArrayOutputStream
+        .capacityLimitedOutputStream(DEFAULT_BUFFER_SIZE);
     InputStream bytesIn = new ByteArrayInputStream(compressedData.array(), 
computeOffset(compressedData),
         compressedData.remaining());
     try (InputStream ios = ZstandardLoader.input(bytesIn, useBufferPool)) {
diff --git 
a/lang/java/avro/src/main/java/org/apache/avro/util/NonCopyingByteArrayOutputStream.java
 
b/lang/java/avro/src/main/java/org/apache/avro/util/NonCopyingByteArrayOutputStream.java
index b72e92e89b..940df2c63c 100644
--- 
a/lang/java/avro/src/main/java/org/apache/avro/util/NonCopyingByteArrayOutputStream.java
+++ 
b/lang/java/avro/src/main/java/org/apache/avro/util/NonCopyingByteArrayOutputStream.java
@@ -21,21 +21,43 @@ package org.apache.avro.util;
 import java.io.ByteArrayOutputStream;
 import java.nio.ByteBuffer;
 
+import org.apache.avro.SystemLimitException;
+
 /**
  * Utility to make data written to an {@link ByteArrayOutputStream} directly
- * available as a {@link ByteBuffer}.
+ * available as a {@link ByteBuffer}.Supports limits to the amount of data 
which
+ * may be written. All decompressors MUST create capacity restricted streams to
+ * prevent maliciously compressed data to trigger memory problems across
+ * threads.
+ *
  */
 public class NonCopyingByteArrayOutputStream extends ByteArrayOutputStream {
 
   /**
-   * Creates a new byte array output stream, with a buffer capacity of the
-   * specified size, in bytes.
+   * Size limit, -1 for no limits.
+   */
+  private final long limit;
+
+  /**
+   * Creates a new byte array output stream, with no size limit.
    *
    * @param size the initial size
    * @throws IllegalArgumentException if size is negative
    */
   public NonCopyingByteArrayOutputStream(int size) {
+    this(size, -1);
+  }
+
+  /**
+   * Creates a new byte array output stream, with a buffer capacity of the
+   * specified size, in bytes, capacity limit as specified in {@code limit}.
+   *
+   * @param size  buffer capacity
+   * @param limit size limit or -1 for no limit.
+   */
+  private NonCopyingByteArrayOutputStream(final int size, final long limit) {
     super(size);
+    this.limit = limit;
   }
 
   /**
@@ -48,4 +70,78 @@ public class NonCopyingByteArrayOutputStream extends 
ByteArrayOutputStream {
   public ByteBuffer asByteBuffer() {
     return ByteBuffer.wrap(super.buf, 0, super.count);
   }
+
+  /**
+   * Check there is capacity to write data. Throws SystemLimitException if the
+   * limit is exceeded.
+   *
+   * @param bytes bytes to add
+   */
+  private void checkCapacity(int bytes) {
+    if (limit > 0) {
+      SystemLimitException.checkMaxDecompressCapacity(limit, size(), bytes);
+    }
+  }
+
+  @Override
+  public synchronized void write(final int b) {
+    checkCapacity(1);
+    super.write(b);
+  }
+
+  @Override
+  public synchronized void write(final byte[] b, final int off, final int len) 
{
+    if (b == null) {
+      throw new NullPointerException();
+    }
+    if (off < 0 || len < 0 || off + len > b.length) {
+      throw new IndexOutOfBoundsException();
+    }
+    checkCapacity(len);
+    super.write(b, off, len);
+  }
+
+  /**
+   * Writes the complete contents of the specified byte array to this output
+   * stream.
+   *
+   * @param b the data
+   * @throws NullPointerException if b is null
+   * @throws SystemLimitException if the limit is exceeded
+   */
+  public void writeBytes(final byte[] b) {
+    if (b == null) {
+      throw new NullPointerException();
+    }
+    checkCapacity(b.length);
+    write(b, 0, b.length);
+  }
+
+  /**
+   * Creates a new byte array output stream, with a buffer capacity of the
+   * specified size, in bytes. The amount of data which can be written to any
+   * output stream is limited by the system property
+   * {@link SystemLimitException#MAX_DECOMPRESS_LENGTH_PROPERTY}
+   *
+   * @param size buffer capacity
+   * @return the output stream
+   */
+  public static NonCopyingByteArrayOutputStream 
capacityLimitedOutputStream(final int size) {
+    final long limit = SystemLimitException.MAX_DECOMPRESS_LENGTH;
+    return new NonCopyingByteArrayOutputStream((int) Math.min(size, limit), 
limit);
+  }
+
+  /**
+   * Creates a new byte array output stream, with a buffer capacity of the
+   * specified size, in bytes, capacity limit as specified in {@code limit}.
+   *
+   * @param size  buffer capacity
+   * @param limit max size of output buffer
+   * @return the output stream
+   */
+  public static NonCopyingByteArrayOutputStream 
capacityLimitedOutputStream(final int size, long limit) {
+    final int initialSize = limit > 0 ? (int) Math.min(size, limit) : size;
+    return new NonCopyingByteArrayOutputStream(initialSize, limit);
+  }
+
 }
diff --git 
a/lang/java/avro/src/test/java/org/apache/avro/util/NonCopyingByteArrayOutputStreamTest.java
 
b/lang/java/avro/src/test/java/org/apache/avro/util/NonCopyingByteArrayOutputStreamTest.java
new file mode 100644
index 0000000000..0919edbd41
--- /dev/null
+++ 
b/lang/java/avro/src/test/java/org/apache/avro/util/NonCopyingByteArrayOutputStreamTest.java
@@ -0,0 +1,93 @@
+/*
+ * 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.avro.util;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.avro.SystemLimitException;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class NonCopyingByteArrayOutputStreamTest {
+
+  /**
+   * Basic test: write then read.
+   */
+  @Test
+  public void testDefaultWriteWorks() throws IOException {
+    NonCopyingByteArrayOutputStream out = new 
NonCopyingByteArrayOutputStream(1);
+    out.write('a');
+    final byte[] b = "string".getBytes();
+    out.write(b, 0, b.length);
+    out.close();
+    final ByteBuffer buffer = out.asByteBuffer();
+    assertEquals('a', buffer.get());
+    for (byte value : b) {
+      assertEquals(value, buffer.get());
+    }
+  }
+
+  /**
+   * Test write limiting.
+   */
+  @Test
+  public void testLimitedWrite() throws IOException {
+    NonCopyingByteArrayOutputStream out = 
NonCopyingByteArrayOutputStream.capacityLimitedOutputStream(1, 4);
+    out.write('a');
+    // it's impossible to go over the limit in a write(bytes) call.
+    final byte[] b = "longstring".getBytes();
+    assertThrows(SystemLimitException.class, () -> out.write(b),
+        "Buffer size 11 (bytes) exceeds maximum allowed size 4.");
+    // we can still write up to the limit...the buffer has not been written to 
yet.
+    out.write(b, 0, 2);
+    out.write('z');
+    // now at end of file, so another write shall fail.
+    assertThrows(SystemLimitException.class, () -> out.write('x'));
+    out.close();
+    // validate everything successfully written is there
+    final ByteBuffer buffer = out.asByteBuffer();
+    for (byte value : "aloz".getBytes()) {
+      assertEquals(value, buffer.get());
+    }
+  }
+
+  @Test
+  public void testLimitedWriteBytes() {
+    NonCopyingByteArrayOutputStream out = 
NonCopyingByteArrayOutputStream.capacityLimitedOutputStream(1, 4);
+    out.writeBytes("abcd".getBytes());
+    assertThrows(SystemLimitException.class, () -> 
out.writeBytes("e".getBytes()));
+  }
+
+  @Test
+  public void testInitialCapacityIsClampedToLimit() throws IOException {
+    NonCopyingByteArrayOutputStream out = 
NonCopyingByteArrayOutputStream.capacityLimitedOutputStream(1024, 4);
+    out.write("abcd".getBytes());
+    assertThrows(SystemLimitException.class, () -> out.write('e'));
+  }
+
+  @Test
+  public void testInnerLimitCheck() throws Throwable {
+    assertThrows(SystemLimitException.class, () -> 
SystemLimitException.checkMaxDecompressCapacity(256L, 0, 100_000));
+    SystemLimitException.checkMaxDecompressCapacity(256L, 0, 256);
+  }
+}

Reply via email to