Copilot commented on code in PR #3861:
URL: https://github.com/apache/avro/pull/3861#discussion_r3566596524


##########
lang/py/avro/io.py:
##########
@@ -795,24 +961,32 @@ def read_array(self, writers_schema: 
avro.schema.ArraySchema, readers_schema: av
         The actual count in this case
         is the absolute value of the count written.
         """
-        read_items = []
+        read_items: List[object] = []
+        min_bytes = _min_bytes_per_element(writers_schema.items)
+        zero_byte_limit, structural_limit = _collection_limits()
         block_count = decoder.read_long()
         while block_count != 0:
             if block_count < 0:
                 block_count = -block_count
                 decoder.skip_long()
+            self._ensure_collection_available(decoder, len(read_items), 
block_count, min_bytes, zero_byte_limit, structural_limit)
             for i in range(block_count):
                 read_items.append(self.read_data(writers_schema.items, 
readers_schema.items, decoder))
             block_count = decoder.read_long()
         return read_items
 
     def skip_array(self, writers_schema: avro.schema.ArraySchema, decoder: 
BinaryDecoder) -> None:
+        min_bytes = _min_bytes_per_element(writers_schema.items)
+        zero_byte_limit, structural_limit = _collection_limits()
+        items_skipped = 0
         block_count = decoder.read_long()
         while block_count != 0:
             if block_count < 0:
                 block_size = decoder.read_long()
                 decoder.skip(block_size)
             else:
+                self._ensure_collection_available(decoder, items_skipped, 
block_count, min_bytes, zero_byte_limit, structural_limit)

Review Comment:
   `skip_array()` applies `_ensure_collection_available()` only for positive 
block counts. For negative block counts (the `-count` form with an explicit 
`block_size`), the code currently bypasses both the bytes-remaining validation 
and the cumulative item caps, so a malicious stream can evade the new 
collection limit checks when the decoder is skipping data (e.g., for schema 
resolution). Normalize negative counts and apply 
`_ensure_collection_available()`/`items_skipped` consistently before skipping 
the block bytes.



##########
lang/py/avro/io.py:
##########
@@ -832,25 +1006,34 @@ def read_map(self, writers_schema: 
avro.schema.MapSchema, readers_schema: avro.s
         The actual count in this case
         is the absolute value of the count written.
         """
-        read_items = {}
+        read_items: Dict[str, object] = {}
+        # Map keys are strings (>= 1 byte length prefix) plus the value.
+        min_bytes = 1 + _min_bytes_per_element(writers_schema.values)
+        zero_byte_limit, structural_limit = _collection_limits()
         block_count = decoder.read_long()
         while block_count != 0:
             if block_count < 0:
                 block_count = -block_count
                 decoder.skip_long()
+            self._ensure_collection_available(decoder, len(read_items), 
block_count, min_bytes, zero_byte_limit, structural_limit)
             for i in range(block_count):
                 key = decoder.read_utf8()
                 read_items[key] = self.read_data(writers_schema.values, 
readers_schema.values, decoder)
             block_count = decoder.read_long()
         return read_items
 
     def skip_map(self, writers_schema: avro.schema.MapSchema, decoder: 
BinaryDecoder) -> None:
+        min_bytes = 1 + _min_bytes_per_element(writers_schema.values)
+        zero_byte_limit, structural_limit = _collection_limits()
+        items_skipped = 0
         block_count = decoder.read_long()
         while block_count != 0:
             if block_count < 0:
                 block_size = decoder.read_long()
                 decoder.skip(block_size)
             else:
+                self._ensure_collection_available(decoder, items_skipped, 
block_count, min_bytes, zero_byte_limit, structural_limit)

Review Comment:
   `skip_map()` has the same gap as `skip_array()`: negative block counts 
bypass `_ensure_collection_available()` and the cumulative `items_skipped` 
accounting. This makes the new size-limit enforcement inconsistent between read 
vs skip paths and allows the cap to be bypassed when skipping map blocks 
encoded with negative counts.



##########
lang/py/avro/test/test_io.py:
##########
@@ -715,6 +843,165 @@ def test_type_exception_record(self) -> None:
             write_datum(datum_to_write, writers_schema)
 
 
+class TestDatumReaderCollectionSizeLimit(unittest.TestCase):
+    """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 them. A huge declared block count of 
such
+    elements is capped so a tiny payload cannot exhaust memory. The limit is
+    configurable via the ``AVRO_MAX_COLLECTION_ITEMS`` environment variable."""
+
+    @staticmethod
+    def _decode(schema_json: str, encoded: bytes) -> object:
+        schema = avro.schema.parse(schema_json)
+        reader = avro.io.DatumReader(schema)
+        with io.BytesIO(encoded) as bio:
+            return reader.read(avro.io.BinaryDecoder(bio))
+
+    @staticmethod
+    def _skip(schema_json: str, encoded: bytes) -> None:
+        schema = avro.schema.parse(schema_json)
+        reader = avro.io.DatumReader(schema)
+        with io.BytesIO(encoded) as bio:
+            reader.skip_data(schema, avro.io.BinaryDecoder(bio))
+
+    @staticmethod
+    def _array_block(count: int, *, negative: bool = False) -> bytes:
+        """One array/map block of ``count`` zero-byte elements + end marker."""
+        buf = io.BytesIO()
+        enc = avro.io.BinaryEncoder(buf)
+        if negative:
+            enc.write_long(-count)  # negative count is followed by a block 
byte-size
+            enc.write_long(0)
+        else:
+            enc.write_long(count)
+        enc.write_long(0)  # end-of-collection marker
+        return buf.getvalue()
+
+    def test_array_of_null_exceeds_default_limit(self) -> None:
+        # The reported exploit: a ~6 byte payload declaring 200,000,000 nulls.
+        self.assertRaises(
+            avro.errors.AvroCollectionSizeException,
+            self._decode,
+            '{"type": "array", "items": "null"}',
+            self._array_block(200_000_000),
+        )
+
+    def test_array_of_null_within_configured_limit_still_reads(self) -> None:
+        with unittest.mock.patch.dict(os.environ, 
{"AVRO_MAX_COLLECTION_ITEMS": "1000"}):
+            result = self._decode('{"type": "array", "items": "null"}', 
self._array_block(1000))
+        self.assertEqual(result, [None] * 1000)
+
+    def test_array_of_null_exceeds_configured_limit(self) -> None:
+        with unittest.mock.patch.dict(os.environ, 
{"AVRO_MAX_COLLECTION_ITEMS": "1000"}):
+            self.assertRaises(
+                avro.errors.AvroCollectionSizeException,
+                self._decode,
+                '{"type": "array", "items": "null"}',
+                self._array_block(1001),
+            )
+
+    def test_array_of_null_cumulative_across_blocks(self) -> None:
+        # Two blocks of 600 nulls each (1200 > 1000) must be rejected on the 
second.
+        buf = io.BytesIO()
+        enc = avro.io.BinaryEncoder(buf)
+        enc.write_long(600)
+        enc.write_long(600)
+        enc.write_long(0)
+        with unittest.mock.patch.dict(os.environ, 
{"AVRO_MAX_COLLECTION_ITEMS": "1000"}):
+            self.assertRaises(
+                avro.errors.AvroCollectionSizeException,
+                self._decode,
+                '{"type": "array", "items": "null"}',
+                buf.getvalue(),
+            )
+
+    def test_array_of_null_negative_block_count(self) -> None:
+        # A negative count encodes abs(count) elements preceded by a block 
size;
+        # after normalization it must still be bounded.
+        with unittest.mock.patch.dict(os.environ, 
{"AVRO_MAX_COLLECTION_ITEMS": "1000"}):
+            self.assertRaises(
+                avro.errors.AvroCollectionSizeException,
+                self._decode,
+                '{"type": "array", "items": "null"}',
+                self._array_block(200_000, negative=True),
+            )
+
+    def test_array_of_zero_length_fixed_exceeds_limit(self) -> None:
+        schema_json = '{"type": "array", "items": {"type": "fixed", "name": 
"empty", "size": 0}}'
+        with unittest.mock.patch.dict(os.environ, 
{"AVRO_MAX_COLLECTION_ITEMS": "1000"}):
+            self.assertRaises(avro.errors.AvroCollectionSizeException, 
self._decode, schema_json, self._array_block(2000))
+
+    def test_array_of_all_null_record_exceeds_limit(self) -> None:
+        schema_json = '{"type": "array", "items": {"type": "record", "name": 
"R", "fields": [{"name": "n", "type": "null"}]}}'
+        with unittest.mock.patch.dict(os.environ, 
{"AVRO_MAX_COLLECTION_ITEMS": "1000"}):
+            self.assertRaises(avro.errors.AvroCollectionSizeException, 
self._decode, schema_json, self._array_block(2000))
+
+    def test_map_of_null_rejected_by_available_bytes(self) -> None:
+        # Map entries always carry a >= 1 byte key, so a huge map<null> is 
bounded
+        # by the bytes-remaining check rather than the zero-byte cap.
+        self.assertRaises(
+            avro.errors.InvalidAvroBinaryEncoding,
+            self._decode,
+            '{"type": "map", "values": "null"}',
+            self._array_block(200_000_000),
+        )
+
+    def test_skip_array_of_null_respects_limit(self) -> None:
+        with unittest.mock.patch.dict(os.environ, 
{"AVRO_MAX_COLLECTION_ITEMS": "1000"}):
+            self.assertRaises(
+                avro.errors.AvroCollectionSizeException,
+                self._skip,
+                '{"type": "array", "items": "null"}',
+                self._array_block(2000),
+            )
+
+    def test_invalid_env_override_falls_back_to_default(self) -> None:
+        with unittest.mock.patch.dict(os.environ, 
{"AVRO_MAX_COLLECTION_ITEMS": "not-a-number"}):
+            with warnings.catch_warnings():
+                warnings.simplefilter("ignore")
+                self.assertEqual(avro.io._max_collection_items(), 
avro.io.DEFAULT_MAX_COLLECTION_ITEMS)
+
+    def test_negative_env_override_falls_back_to_default(self) -> None:
+        with unittest.mock.patch.dict(os.environ, 
{"AVRO_MAX_COLLECTION_ITEMS": "-5"}):
+            with warnings.catch_warnings():
+                warnings.simplefilter("ignore")
+                self.assertEqual(avro.io._max_collection_items(), 
avro.io.DEFAULT_MAX_COLLECTION_ITEMS)
+
+    def test_env_override_is_honored(self) -> None:
+        with unittest.mock.patch.dict(os.environ, 
{"AVRO_MAX_COLLECTION_ITEMS": "7"}):
+            self.assertEqual(avro.io._max_collection_items(), 7)
+            result = self._decode('{"type": "array", "items": "null"}', 
self._array_block(7))
+        self.assertEqual(result, [None] * 7)
+
+    def test_collection_limits_env_caps_both(self) -> None:
+        # When set, AVRO_MAX_COLLECTION_ITEMS caps both the zero-byte and the
+        # structural limit; unset, they differ (tighter zero-byte default).
+        with unittest.mock.patch.dict(os.environ, 
{"AVRO_MAX_COLLECTION_ITEMS": "1234"}):
+            self.assertEqual(avro.io._collection_limits(), (1234, 1234))
+        os.environ.pop("AVRO_MAX_COLLECTION_ITEMS", None)
+        self.assertEqual(
+            avro.io._collection_limits(),
+            (avro.io.DEFAULT_MAX_COLLECTION_ITEMS, 
avro.io.DEFAULT_MAX_COLLECTION_STRUCTURAL),
+        )

Review Comment:
   `test_collection_limits_env_caps_both()` mutates the real process 
environment via `os.environ.pop(...)` outside of a patch context. If a 
developer (or CI) runs tests with `AVRO_MAX_COLLECTION_ITEMS` already set, this 
test will delete it for the remainder of the test run, potentially causing 
unrelated tests to behave differently. Save/restore the original value (or keep 
the unset assertion inside a temporary env override) so the test is hermetic.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to