This is an automated email from the ASF dual-hosted git repository.
RyanSkraba pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/avro.git
The following commit(s) were added to refs/heads/main by this push:
new d28835a446 AVRO-4296: [python] Bound zero-byte collection elements per
datum, not per collection (#3926)
d28835a446 is described below
commit d28835a446999db569cefa94dd819c7c036452a4
Author: Ismaël Mejía <[email protected]>
AuthorDate: Fri Aug 7 14:54:54 2026 +0200
AVRO-4296: [python] Bound zero-byte collection elements per datum, not per
collection (#3926)
The AVRO-4296 zero-byte-element cap (null, zero-length fixed, all-zero-byte
records) was enforced per collection: read_array/read_map each started
counting
from zero. Because a container file carries its own schema, an attacker can
declare a record with many array<null> fields, each block individually
under the
limit but jointly unbounded, so a tiny payload still drives a huge
allocation
(e.g. 16 array<null> fields of ~10M each: an ~80 byte record that exhausts
memory, or 8 fields that burn tens of seconds of CPU).
Track the cumulative zero-byte element count on the DatumReader across a
single
decoded datum, reset at the start of each top-level read() (the boundary
DataFileReader uses per record), and check it in
_ensure_collection_available.
Positive-size elements are unchanged: they are naturally bounded per
collection
because decoding consumes input and the bytes-remaining check shrinks as the
position advances.
Adds regression tests for a multi-field record that exceeds the cap in
aggregate
and for a within-limit record that still reads (and confirms the budget
resets
between datums).
---
lang/py/avro/io.py | 60 ++++++++++++++++++++++++++++++++------------
lang/py/avro/test/test_io.py | 46 +++++++++++++++++++++++++++++++++
2 files changed, 90 insertions(+), 16 deletions(-)
diff --git a/lang/py/avro/io.py b/lang/py/avro/io.py
index a67230f073..f5063a6686 100644
--- a/lang/py/avro/io.py
+++ b/lang/py/avro/io.py
@@ -686,14 +686,16 @@ class BinaryEncoder:
# elements in any collection, allocated from a single decode.
MAX_COLLECTION_ITEMS_ENV = "AVRO_MAX_COLLECTION_ITEMS"
-# Default maximum number of zero-byte-encoded collection elements to allocate.
-# Elements whose schema encodes to zero bytes (``null``, a zero-length
``fixed``,
-# or a record with only zero-byte fields) consume no input, so the
bytes-remaining
-# check cannot bound their count; without a cap a tiny payload can declare a
huge
-# block count and exhaust memory. A legitimate collection of zero-byte
elements is
-# small, so this default is generous while still rejecting pathological input.
It
-# can be raised (or lowered) with the ``AVRO_MAX_COLLECTION_ITEMS`` environment
-# variable.
+# Default maximum number of zero-byte-encoded collection elements to allocate
+# across a single decoded datum. Elements whose schema encodes to zero bytes
+# (``null``, a zero-length ``fixed``, or a record with only zero-byte fields)
+# consume no input, so the bytes-remaining check cannot bound their count;
without
+# a cap a tiny payload can declare a huge block count and exhaust memory. The
cap
+# is cumulative over the whole datum rather than per collection, because a
record's
+# schema can declare many such collection fields, each individually under the
limit
+# but jointly unbounded. A legitimate datum of zero-byte elements is small, so
this
+# default is generous while still rejecting pathological input. It can be
raised (or
+# lowered) with the ``AVRO_MAX_COLLECTION_ITEMS`` environment variable.
DEFAULT_MAX_COLLECTION_ITEMS = 10_000_000
# Default structural cap on the number of elements in any array or map (a
@@ -805,6 +807,15 @@ class DatumReader:
"""
self._writers_schema = writers_schema
self._readers_schema = readers_schema
+ # Cumulative number of zero-byte-encoded collection elements (e.g. an
+ # array of nulls) allocated while decoding the *current* datum. Reset
at
+ # the start of each top-level read(). Because such elements consume no
+ # input bytes, the bytes-remaining check cannot bound them; capping the
+ # count per collection is not enough either, since a single record's
+ # schema can declare many collection fields, each individually under
the
+ # limit but together unbounded. The cap is therefore applied across the
+ # whole datum. See _ensure_collection_available.
+ self._zero_byte_items_read = 0
@property
def writers_schema(self) -> Optional[avro.schema.Schema]:
@@ -828,6 +839,12 @@ class DatumReader:
reader_schema = self.readers_schema
if reader_schema is None:
reader_schema = self.writers_schema
+ # Start a fresh zero-byte-element budget for this datum. The cap bounds
+ # the *cumulative* number of zero-byte collection elements decoded
across
+ # every collection in this datum, not per collection, so a record made
of
+ # many small collection fields cannot bypass it (see
+ # _ensure_collection_available).
+ self._zero_byte_items_read = 0
return self.read_data(self.writers_schema, reader_schema, decoder)
def read_data(self, writers_schema: avro.schema.Schema, readers_schema:
avro.schema.Schema, decoder: "BinaryDecoder") -> object:
@@ -982,8 +999,8 @@ class DatumReader:
def skip_enum(self, writers_schema: avro.schema.EnumSchema, decoder:
BinaryDecoder) -> None:
return decoder.skip_int()
- @staticmethod
def _ensure_collection_available(
+ self,
decoder: BinaryDecoder,
existing: int,
count: int,
@@ -997,10 +1014,17 @@ class DatumReader:
For elements with a positive minimum on-wire size, the declared count
is
checked against the bytes actually remaining and against a structural
- limit (an overflow/defense-in-depth cap). For zero-byte elements (e.g.
an
- array of nulls), which consume no input and so cannot be bounded by the
- bytes remaining, the cumulative count is checked against the (tighter)
- zero-byte limit.
+ limit (an overflow/defense-in-depth cap); both are naturally bounded
per
+ collection because decoding consumes input, so ``existing`` is the
count
+ already read in *this* collection.
+
+ For zero-byte elements (e.g. an array of nulls), which consume no
input,
+ neither the bytes remaining nor a per-collection count can bound them:
a
+ single datum's schema may declare many such collections (one per record
+ field), each individually small but jointly unbounded. Their count is
+ therefore accumulated on the reader across the whole datum
+ (``self._zero_byte_items_read``, reset per top-level ``read()``) and
+ checked against the (tighter) zero-byte limit.
"""
if count <= 0:
return
@@ -1024,10 +1048,14 @@ class DatumReader:
f"Cannot read a collection of more than {structural_limit}
elements "
f"(declared {existing + count}); raise the
{MAX_COLLECTION_ITEMS_ENV} limit if this is legitimate."
)
- elif existing + count > zero_byte_limit:
+ return
+ # Zero-byte element type: bound the cumulative count across the datum.
+ self._zero_byte_items_read += count
+ if self._zero_byte_items_read > zero_byte_limit:
raise avro.errors.AvroCollectionSizeException(
- f"Cannot read a collection of more than {zero_byte_limit}
zero-byte elements "
- f"(declared {existing + count}); raise the
{MAX_COLLECTION_ITEMS_ENV} limit if this is legitimate."
+ f"Cannot read more than {zero_byte_limit} zero-byte collection
elements "
+ f"in a single datum (reached {self._zero_byte_items_read});
raise the "
+ f"{MAX_COLLECTION_ITEMS_ENV} limit if this is legitimate."
)
def read_array(self, writers_schema: avro.schema.ArraySchema,
readers_schema: avro.schema.ArraySchema, decoder: BinaryDecoder) ->
List[object]:
diff --git a/lang/py/avro/test/test_io.py b/lang/py/avro/test/test_io.py
index 9ee39b1937..1a71e0598e 100644
--- a/lang/py/avro/test/test_io.py
+++ b/lang/py/avro/test/test_io.py
@@ -1142,6 +1142,52 @@ class
TestDatumReaderCollectionSizeLimit(unittest.TestCase):
buf.getvalue(),
)
+ def test_record_of_array_of_null_fields_cumulative_across_datum(self) ->
None:
+ # AVRO-4296 follow-up: the cap is per decoded datum, not per
collection.
+ # A record whose schema declares several array<null> fields, each block
+ # individually under the limit, must still be rejected once their
combined
+ # count exceeds it. Here two fields of 600 nulls each (1200 > 1000) are
+ # rejected on the second field, mirroring the multi-field
container-file
+ # amplification (many small collection fields, unbounded in aggregate).
+ schema_json = json.dumps(
+ {
+ "type": "record",
+ "name": "R",
+ "fields": [
+ {"name": "a", "type": {"type": "array", "items": "null"}},
+ {"name": "b", "type": {"type": "array", "items": "null"}},
+ ],
+ }
+ )
+ payload = self._array_block(600) + self._array_block(600)
+ with unittest.mock.patch.dict(os.environ,
{"AVRO_MAX_COLLECTION_ITEMS": "1000"}):
+ self.assertRaises(avro.errors.AvroCollectionSizeException,
self._decode, schema_json, payload)
+
+ def test_record_of_array_of_null_fields_within_datum_limit_reads(self) ->
None:
+ # The complement of the amplification test: two array<null> fields
whose
+ # combined count stays under the per-datum cap decode normally, and a
+ # fresh read() resets the budget so a subsequent datum is not
penalized.
+ schema_json = json.dumps(
+ {
+ "type": "record",
+ "name": "R",
+ "fields": [
+ {"name": "a", "type": {"type": "array", "items": "null"}},
+ {"name": "b", "type": {"type": "array", "items": "null"}},
+ ],
+ }
+ )
+ payload = self._array_block(400) + self._array_block(400)
+ schema = avro.schema.parse(schema_json)
+ reader = avro.io.DatumReader(schema)
+ with unittest.mock.patch.dict(os.environ,
{"AVRO_MAX_COLLECTION_ITEMS": "1000"}):
+ with io.BytesIO(payload) as bio:
+ self.assertEqual(reader.read(avro.io.BinaryDecoder(bio)),
{"a": [None] * 400, "b": [None] * 400})
+ # The budget resets per top-level read(): decoding the same datum
+ # again on the same reader must not accumulate across datums.
+ with io.BytesIO(payload) as bio:
+ self.assertEqual(reader.read(avro.io.BinaryDecoder(bio)),
{"a": [None] * 400, "b": [None] * 400})
+
def test_map_duplicate_keys_counted_cumulatively(self) -> None:
# Two blocks of 600 pairs that repeat the SAME key: len(read_items)
would
# be 1, so a separate decoded-pair counter is needed to reject 1200 >
1000.