This is an automated email from the ASF dual-hosted git repository.
RyanSkraba pushed a commit to branch branch-1.12
in repository https://gitbox.apache.org/repos/asf/avro.git
The following commit(s) were added to refs/heads/branch-1.12 by this push:
new 478ed0b433 AVRO-4290: [python] Enforce a maximum decompressed block
size (#3850)
478ed0b433 is described below
commit 478ed0b433d9bd1db3a2781f8b6b351b9b39d735
Author: Ismaël Mejía <[email protected]>
AuthorDate: Fri Aug 7 09:14:14 2026 +0200
AVRO-4290: [python] Enforce a maximum decompressed block size (#3850)
* AVRO-4290: [python] Enforce a maximum decompressed block size
When reading a data file, each block is decompressed according to the
file's codec. A block with a very high compression ratio (or a malformed
block) could expand to far more memory than its compressed size. Enforce a
configurable maximum decompressed size across the deflate, bzip2, snappy and
zstandard codecs, mirroring the Java SDK's decompression limit (AVRO-4247).
The limit defaults to 200 MiB and can be overridden with the
AVRO_MAX_DECOMPRESS_LENGTH environment variable; exceeding it raises
AvroDecompressionSizeException.
Assisted-by: GitHub Copilot:claude-opus-4.8
* AVRO-4290: [python] Address review: avoid deflate copy; zstd
check-before-extend
- DeflateCodec.decompress accumulates into a bytearray so the flush()
output is
appended in place instead of creating an extra full-size copy of the
already
decompressed data.
- ZstandardCodec.decompress checks len(uncompressed) + len(chunk) before
extending, so the buffer never grows past the configured limit.
Assisted-by: GitHub Copilot:claude-opus-4.8
* AVRO-4290: [python] Verify bzip2 stream EOF and drain fully
BZip2Codec.decompress now loops the BZ2Decompressor: it drains all buffered
output (bounded by the limit), verifies the stream reached EOF (rejecting a
truncated/corrupt block with InvalidAvroBinaryEncoding), and handles
concatenated bzip2 streams as bz2.decompress does. Add a truncated-block
test.
Assisted-by: GitHub Copilot:claude-opus-4.8
* AVRO-4290: [python] Reject truncated deflate and short snappy blocks
Address review feedback:
- DeflateCodec: after flushing the decompressor, verify decompressor.eof so
a
truncated/incomplete raw-deflate block is rejected with
InvalidAvroBinaryEncoding (zlib.decompress used to raise for this; the
decompressobj-based size cap otherwise silently accepted partial output).
- SnappyCodec: validate the block length is >= 4 before reading length - 4
bytes, raising a codec-specific InvalidAvroBinaryEncoding instead of
falling
through to the generic decoder error.
Assisted-by: GitHub Copilot:claude-opus-4.8
* AVRO-4290: [python] Add a truncated-deflate-block rejection test
Cover the new DeflateCodec behavior (raising InvalidAvroBinaryEncoding when
the
end-of-stream marker isn't reached) with a test that truncates a valid
deflate
block and asserts decompress() rejects it, mirroring the bzip2 truncation
test.
Assisted-by: GitHub Copilot:claude-opus-4.8
* AVRO-4290: [python] Bound the deflate flush() output too
The previous fix bounded decompress(data, limit+1) but then called
decompressor.flush() with no limit, so the residual output (from the
unconsumed input left by max_length) could still expand unbounded. Drain the
unconsumed_tail in a loop with a per-call max_length and pass a bounded
length
to flush(), so the accumulated output can never exceed the limit by more
than
one byte before being rejected.
Assisted-by: GitHub Copilot:claude-opus-4.8
* AVRO-4290: [python] Clamp AVRO_MAX_DECOMPRESS_LENGTH to sys.maxsize
_max_decompress_length() returned the raw parsed int. An absurdly large
override
is passed as max_length to zlib/bz2 decompress(), which raise OverflowError
when
it exceeds Py_ssize_t. Clamp to sys.maxsize so an oversized override is
honored
as "effectively unbounded" instead of producing a confusing OverflowError.
Assisted-by: GitHub Copilot:claude-opus-4.8
* AVRO-4290: [python] Avoid limit+1 Py_ssize_t overflow at sys.maxsize
After clamping AVRO_MAX_DECOMPRESS_LENGTH to sys.maxsize, the deflate/bz2
paths
still computed `limit + 1` for the decompress() max_length, which overflows
Py_ssize_t (and raises OverflowError) when limit == sys.maxsize. Add
_decompress_read_ceiling(), which returns limit + 1 normally but sys.maxsize
when the limit is already sys.maxsize (no realistic block can exceed it),
and
use it for the bounded read/flush in both paths.
Assisted-by: GitHub Copilot:claude-opus-4.8
---
lang/py/avro/codecs.py | 138 ++++++++++++++++++++++++++++++++++++++-
lang/py/avro/errors.py | 8 +++
lang/py/avro/test/test_codecs.py | 127 +++++++++++++++++++++++++++++++++++
3 files changed, 271 insertions(+), 2 deletions(-)
diff --git a/lang/py/avro/codecs.py b/lang/py/avro/codecs.py
index e1655fef43..6828483335 100644
--- a/lang/py/avro/codecs.py
+++ b/lang/py/avro/codecs.py
@@ -29,6 +29,8 @@ converting charsets
(https://docs.python.org/3/library/codecs.html).
import abc
import binascii
import io
+import os
+import sys
import struct
import zlib
from typing import Dict, Tuple, Type
@@ -41,6 +43,73 @@ import avro.io
#
STRUCT_CRC32 = struct.Struct(">I") # big-endian unsigned int
+# Name of the environment variable used to override the default maximum size of
+# a single decompressed data-file block.
+MAX_DECOMPRESS_LENGTH_ENV = "AVRO_MAX_DECOMPRESS_LENGTH"
+
+# Default upper bound, in bytes, on the size a single data-file block may
+# decompress to. A block with a very high compression ratio (or a malformed
+# block) can otherwise expand to far more memory than its compressed size.
+# Reading a block that would decompress beyond this limit raises an
+# :class:`avro.errors.AvroDecompressionSizeException`. This mirrors the Java
+# SDK's ``org.apache.avro.limits.decompress.maxLength`` limit (AVRO-4247). The
+# default may be overridden with the ``AVRO_MAX_DECOMPRESS_LENGTH`` environment
+# variable.
+DEFAULT_MAX_DECOMPRESS_LENGTH = 200 * 1024 * 1024 # 200 MiB
+
+
+def _max_decompress_length() -> int:
+ """Return the maximum decompressed block size, honoring the environment
override."""
+ value = os.environ.get(MAX_DECOMPRESS_LENGTH_ENV)
+ if value is None:
+ return DEFAULT_MAX_DECOMPRESS_LENGTH
+ try:
+ parsed = int(value)
+ except ValueError:
+ return DEFAULT_MAX_DECOMPRESS_LENGTH
+ if parsed <= 0:
+ return DEFAULT_MAX_DECOMPRESS_LENGTH
+ # Clamp to sys.maxsize so the value stays a valid Py_ssize_t: it is passed
as
+ # the max_length to zlib/bz2 decompress(), which raise OverflowError for a
+ # value that does not fit. sys.maxsize is already far larger than any real
+ # block, so clamping only affects absurd overrides.
+ return min(parsed, sys.maxsize)
+
+
+def _raise_decompression_too_large(limit: int) -> None:
+ raise avro.errors.AvroDecompressionSizeException(f"Decompressed block size
exceeds the maximum allowed of {limit} bytes")
+
+
+def _decompress_read_ceiling(limit: int) -> int:
+ """Return limit + 1 (one byte past the limit, to detect an over-limit
block),
+ but never exceeding sys.maxsize so the value stays a valid Py_ssize_t.
+
+ zlib/bz2 decompress() raise OverflowError for a max_length above
Py_ssize_t.
+ When the limit is already sys.maxsize (an oversized env override, clamped
by
+ _max_decompress_length), no realistic block can exceed it, so returning
+ sys.maxsize instead of sys.maxsize + 1 is safe.
+ """
+ return limit if limit >= sys.maxsize else limit + 1
+
+
+def _snappy_uncompressed_length(data: bytes) -> "int | None":
+ """Return the uncompressed length declared in a raw Snappy block header.
+
+ The Snappy format prefixes the compressed data with the uncompressed length
+ encoded as a little-endian base-128 varint. Returns ``None`` if the header
+ cannot be parsed.
+ """
+ result = 0
+ shift = 0
+ for byte in data:
+ result |= (byte & 0x7F) << shift
+ if not (byte & 0x80):
+ return result
+ shift += 7
+ if shift > 63:
+ break
+ return None
+
def _check_crc32(bytes_: bytes, checksum: bytes) -> None:
if binascii.crc32(bytes_) & 0xFFFFFFFF != STRUCT_CRC32.unpack(checksum)[0]:
@@ -123,7 +192,32 @@ class DeflateCodec(Codec):
data = readers_decoder.read_bytes()
# -15 is the log of the window size; negative indicates
# "raw" (no zlib headers) decompression. See zlib.h.
- uncompressed = zlib.decompress(data, -15)
+ limit = _max_decompress_length()
+ decompressor = zlib.decompressobj(-15)
+ # Decompress in bounded steps: request at most (limit + 1 - produced)
+ # bytes each call so the accumulated output can never exceed the limit
by
+ # more than one byte before being rejected. decompress()'s max_length
+ # leaves unconsumed input in unconsumed_tail, and flush() would
otherwise
+ # emit the remainder unbounded, so drain the tail in a loop and bound
the
+ # final flush too.
+ ceiling = _decompress_read_ceiling(limit)
+ uncompressed = bytearray()
+ pending = data
+ while True:
+ want = ceiling - len(uncompressed)
+ uncompressed += decompressor.decompress(pending, want)
+ if len(uncompressed) > limit:
+ _raise_decompression_too_large(limit)
+ pending = decompressor.unconsumed_tail
+ if not pending:
+ break
+ uncompressed += decompressor.flush(ceiling - len(uncompressed))
+ if len(uncompressed) > limit:
+ _raise_decompression_too_large(limit)
+ if not decompressor.eof:
+ # The end-of-stream marker was not reached: the block is truncated
or
+ # corrupt. zlib.decompress() used to raise for this; preserve that.
+ raise avro.errors.InvalidAvroBinaryEncoding("Truncated or corrupt
deflate block")
return avro.io.BinaryDecoder(io.BytesIO(uncompressed))
@@ -139,7 +233,31 @@ if has_bzip2:
def decompress(readers_decoder: avro.io.BinaryDecoder) ->
avro.io.BinaryDecoder:
length = readers_decoder.read_long()
data = readers_decoder.read(length)
- uncompressed = bz2.decompress(data)
+ limit = _max_decompress_length()
+ ceiling = _decompress_read_ceiling(limit)
+ uncompressed = bytearray()
+ decompressor = bz2.BZ2Decompressor()
+ input_data = data
+ while True:
+ # Request enough to detect exceeding the limit without
allocating
+ # the full (potentially huge) output.
+ want = ceiling - len(uncompressed)
+ if want <= 0:
+ want = 1
+ uncompressed += decompressor.decompress(input_data, want)
+ input_data = b"" # subsequent calls drain buffered output
+ if len(uncompressed) > limit:
+ _raise_decompression_too_large(limit)
+ if decompressor.eof:
+ # Handle concatenated bzip2 streams as bz2.decompress does.
+ input_data = decompressor.unused_data
+ if not input_data:
+ break
+ decompressor = bz2.BZ2Decompressor()
+ elif decompressor.needs_input:
+ # All input consumed but the stream did not end: truncated
or corrupt.
+ raise avro.errors.InvalidAvroBinaryEncoding("Truncated or
corrupt bzip2 block")
+ # otherwise output was capped for this call; loop to drain more
return avro.io.BinaryDecoder(io.BytesIO(uncompressed))
@@ -157,8 +275,20 @@ if has_snappy:
def decompress(readers_decoder: avro.io.BinaryDecoder) ->
avro.io.BinaryDecoder:
# Compressed data includes a 4-byte CRC32 checksum
length = readers_decoder.read_long()
+ if length < 4:
+ raise avro.errors.InvalidAvroBinaryEncoding(
+ f"Invalid snappy block length {length}: must be at least 4
bytes for the trailing CRC32 checksum"
+ )
data = readers_decoder.read(length - 4)
+ limit = _max_decompress_length()
+ # The Snappy block header declares the uncompressed length as a
+ # varint; reject an over-large block before allocating for it.
+ declared = _snappy_uncompressed_length(data)
+ if declared is not None and declared > limit:
+ _raise_decompression_too_large(limit)
uncompressed = snappy.decompress(data)
+ if len(uncompressed) > limit:
+ _raise_decompression_too_large(limit)
checksum = readers_decoder.read(4)
_check_crc32(uncompressed, checksum)
return avro.io.BinaryDecoder(io.BytesIO(uncompressed))
@@ -176,6 +306,7 @@ if has_zstandard:
def decompress(readers_decoder: avro.io.BinaryDecoder) ->
avro.io.BinaryDecoder:
length = readers_decoder.read_long()
data = readers_decoder.read(length)
+ limit = _max_decompress_length()
uncompressed = bytearray()
dctx = zstd.ZstdDecompressor()
with dctx.stream_reader(io.BytesIO(data)) as reader:
@@ -183,6 +314,9 @@ if has_zstandard:
chunk = reader.read(16384)
if not chunk:
break
+ # Check before extending so the buffer never grows past
the limit.
+ if len(uncompressed) + len(chunk) > limit:
+ _raise_decompression_too_large(limit)
uncompressed.extend(chunk)
return avro.io.BinaryDecoder(io.BytesIO(uncompressed))
diff --git a/lang/py/avro/errors.py b/lang/py/avro/errors.py
index 3939a364fa..fd0287c8dd 100644
--- a/lang/py/avro/errors.py
+++ b/lang/py/avro/errors.py
@@ -92,6 +92,14 @@ class AvroOutOfScaleException(AvroTypeException):
return super().__init__(f"The exponent of {datum}, {exponent}, is too
large for the schema scale of {scale}")
+class AvroDecompressionSizeException(AvroException):
+ """Raised when a data-file block decompresses to more than the configured
maximum.
+
+ This guards against unbounded memory allocation when a highly compressible
+ (or malformed) block would expand to far more than its compressed size.
+ """
+
+
class SchemaResolutionException(AvroException):
def __init__(self, fail_msg, writers_schema=None, readers_schema=None,
*args):
writers_message = f"\nWriter's Schema: {_safe_pretty(writers_schema)}"
if writers_schema else ""
diff --git a/lang/py/avro/test/test_codecs.py b/lang/py/avro/test/test_codecs.py
new file mode 100644
index 0000000000..5a462f0eca
--- /dev/null
+++ b/lang/py/avro/test/test_codecs.py
@@ -0,0 +1,127 @@
+#!/usr/bin/env python3
+
+##
+# 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
+#
+# https://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.
+
+"""Tests that data-file block decompression enforces a maximum output size.
+
+A block with a very high compression ratio (or a malformed block) can expand to
+far more memory than its compressed size; these tests ensure that decompressing
+such a block raises an error instead of allocating without bound.
+"""
+
+import io
+import os
+import unittest
+from typing import Type
+
+import avro.codecs
+import avro.errors
+import avro.io
+
+
+class TestDecompressionSizeLimit(unittest.TestCase):
+ LIMIT = 1024
+
+ def setUp(self) -> None:
+ self._previous = os.environ.get(avro.codecs.MAX_DECOMPRESS_LENGTH_ENV)
+ os.environ[avro.codecs.MAX_DECOMPRESS_LENGTH_ENV] = str(self.LIMIT)
+
+ def tearDown(self) -> None:
+ if self._previous is None:
+ os.environ.pop(avro.codecs.MAX_DECOMPRESS_LENGTH_ENV, None)
+ else:
+ os.environ[avro.codecs.MAX_DECOMPRESS_LENGTH_ENV] = self._previous
+
+ def _decoder_for(self, codec: Type[avro.codecs.Codec], data: bytes) ->
avro.io.BinaryDecoder:
+ """Compress `data` with the codec and wrap it as a data-file block."""
+ compressed, _ = codec.compress(data)
+ buffer = io.BytesIO()
+ encoder = avro.io.BinaryEncoder(buffer)
+ encoder.write_long(len(compressed))
+ buffer.write(compressed)
+ return avro.io.BinaryDecoder(io.BytesIO(buffer.getvalue()))
+
+ def _assert_over_limit_rejected(self, codec: Type[avro.codecs.Codec]) ->
None:
+ # A block of zeros far larger than the limit but that compresses tiny.
+ decoder = self._decoder_for(codec, b"\x00" * (self.LIMIT * 8))
+ with self.assertRaises(avro.errors.AvroDecompressionSizeException):
+ codec.decompress(decoder)
+
+ def _assert_within_limit_ok(self, codec: Type[avro.codecs.Codec]) -> None:
+ payload = b"the quick brown fox " * 10 # well under the limit
+ decoder = self._decoder_for(codec, payload)
+ self.assertEqual(payload, codec.decompress(decoder).reader.read())
+
+ def test_deflate_over_limit(self) -> None:
+ self._assert_over_limit_rejected(avro.codecs.DeflateCodec)
+
+ def test_deflate_within_limit(self) -> None:
+ self._assert_within_limit_ok(avro.codecs.DeflateCodec)
+
+ def test_deflate_truncated_block_rejected(self) -> None:
+ """A truncated deflate block must be rejected rather than silently
accepted."""
+ compressed, _ = avro.codecs.DeflateCodec.compress(b"the quick brown
fox " * 10)
+ truncated = compressed[:-4] # drop the tail so the end-of-stream
marker is never reached
+ buffer = io.BytesIO()
+ encoder = avro.io.BinaryEncoder(buffer)
+ encoder.write_long(len(truncated))
+ buffer.write(truncated)
+ decoder = avro.io.BinaryDecoder(io.BytesIO(buffer.getvalue()))
+ with self.assertRaises(avro.errors.InvalidAvroBinaryEncoding):
+ avro.codecs.DeflateCodec.decompress(decoder)
+
+ @unittest.skipUnless(avro.codecs.has_bzip2, "bzip2 not available")
+ def test_bzip2_over_limit(self) -> None:
+ self._assert_over_limit_rejected(avro.codecs.BZip2Codec)
+
+ @unittest.skipUnless(avro.codecs.has_bzip2, "bzip2 not available")
+ def test_bzip2_within_limit(self) -> None:
+ self._assert_within_limit_ok(avro.codecs.BZip2Codec)
+
+ @unittest.skipUnless(avro.codecs.has_bzip2, "bzip2 not available")
+ def test_bzip2_truncated_block_rejected(self) -> None:
+ """A truncated bzip2 block must be rejected rather than silently
accepted."""
+ compressed, _ = avro.codecs.BZip2Codec.compress(b"the quick brown fox
" * 10)
+ truncated = compressed[:-4] # drop the tail so the stream never
reaches EOF
+ buffer = io.BytesIO()
+ encoder = avro.io.BinaryEncoder(buffer)
+ encoder.write_long(len(truncated))
+ buffer.write(truncated)
+ decoder = avro.io.BinaryDecoder(io.BytesIO(buffer.getvalue()))
+ with self.assertRaises(avro.errors.InvalidAvroBinaryEncoding):
+ avro.codecs.BZip2Codec.decompress(decoder)
+
+ @unittest.skipUnless(avro.codecs.has_snappy, "snappy not available")
+ def test_snappy_over_limit(self) -> None:
+ self._assert_over_limit_rejected(avro.codecs.SnappyCodec)
+
+ @unittest.skipUnless(avro.codecs.has_snappy, "snappy not available")
+ def test_snappy_within_limit(self) -> None:
+ self._assert_within_limit_ok(avro.codecs.SnappyCodec)
+
+ @unittest.skipUnless(avro.codecs.has_zstandard, "zstandard not available")
+ def test_zstandard_over_limit(self) -> None:
+ self._assert_over_limit_rejected(avro.codecs.ZstandardCodec)
+
+ @unittest.skipUnless(avro.codecs.has_zstandard, "zstandard not available")
+ def test_zstandard_within_limit(self) -> None:
+ self._assert_within_limit_ok(avro.codecs.ZstandardCodec)
+
+
+if __name__ == "__main__":
+ unittest.main()