AVRO-4282: [python] Bound collection size when decoding arrays and maps#3845
AVRO-4282: [python] Bound collection size when decoding arrays and maps#3845iemejia wants to merge 7 commits into
Conversation
The block count of an array or map is read from the input and drives allocation of the resulting collection. A very large or malformed block count (for example from truncated input) could request an unbounded allocation. Validate the block count per block and cumulatively against a configurable maximum (AVRO_MAX_COLLECTION_ITEMS), mirroring the Java SDK's collection item limit, and raise AvroCollectionSizeException when exceeded. Assisted-by: GitHub Copilot:claude-opus-4.8
Use a running pair count rather than len(read_items) for the cumulative collection check in read_map: repeated keys collapse in the dict and would otherwise let a stream rewriting the same key bypass the configured maximum. Assisted-by: GitHub Copilot:claude-opus-4.8
|
Pushed a small hardening follow-up (eb6a031): |
Add the second blank line after check_max_collection_items so the module is correctly formatted per ruff (two blank lines before the following top-level statement). Assisted-by: GitHub Copilot:claude-opus-4.8
The cumulative-limit check calls len(read_items) before the first append, so mypy (strict) can no longer infer the element type. Annotate the list as List[object]. Assisted-by: GitHub Copilot:claude-opus-4.8
There was a problem hiding this comment.
Pull request overview
This PR hardens the Python Avro decoder against unbounded allocations when reading arrays and maps from potentially malformed or hostile input, by enforcing a configurable maximum collection size and raising a dedicated exception when exceeded.
Changes:
- Introduces a configurable max-items limit for decoded arrays/maps (defaulting to
2^31 - 8, overrideable viaAVRO_MAX_COLLECTION_ITEMSorDatumReader.max_collection_items). - Enforces the limit during array/map decoding (including cumulative multi-block limits and repeated-key map cases).
- Adds unit tests covering default-limit rejection, per-reader overrides, cumulative limits, negative-count handling, and within-limit decoding.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| lang/py/avro/io.py | Adds configurable collection-size limit and enforces it during array/map decoding. |
| lang/py/avro/errors.py | Adds AvroCollectionSizeException for size-limit violations. |
| lang/py/avro/test/test_io.py | Adds TestCollectionSizeLimit and wires it into load_tests. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| try: | ||
| parsed = int(value) | ||
| except ValueError: | ||
| warnings.warn(f"Ignoring invalid {MAX_COLLECTION_ITEMS_ENV} value: {value!r}", avro.errors.AvroWarning) |
There was a problem hiding this comment.
Fixed in fdfc426 — both warnings.warn calls now pass an avro.errors.AvroWarning instance, matching the codebase convention.
| warnings.warn(f"Ignoring invalid {MAX_COLLECTION_ITEMS_ENV} value: {value!r}", avro.errors.AvroWarning) | ||
| return DEFAULT_MAX_COLLECTION_ITEMS | ||
| if parsed < 0: | ||
| warnings.warn(f"Ignoring negative {MAX_COLLECTION_ITEMS_ENV} value: {value!r}", avro.errors.AvroWarning) |
There was a problem hiding this comment.
Fixed in fdfc426 (same change — instance form for both warnings).
| while block_count != 0: | ||
| if block_count < 0: | ||
| block_count = -block_count | ||
| decoder.skip_long() | ||
| check_max_collection_items(len(read_items), block_count, self.max_collection_items) |
There was a problem hiding this comment.
Good catch — fixed in fdfc426. skip_array now applies the same cumulative max_collection_items check on the positive per-item skip loop (the negative path is already bounded by skip(block_size)). Added test_skip_array_respects_limit.
| while block_count != 0: | ||
| if block_count < 0: | ||
| block_count = -block_count | ||
| decoder.skip_long() | ||
| check_max_collection_items(items_read, block_count, self.max_collection_items) |
There was a problem hiding this comment.
Fixed in fdfc426 — skip_map bounds the positive per-item skip loop the same way. Added test_skip_map_respects_limit.
- Apply the cumulative collection limit in skip_array/skip_map: the positive per-item skip loop can otherwise be driven unbounded by a large untrusted block count (CPU DoS), even when items are cheap to skip. - Pass AvroWarning instances to warnings.warn to match the codebase convention. - Add tests for the skip_array/skip_map limits. Assisted-by: GitHub Copilot:claude-opus-4.8
| :param items: the number of items in the next block to be read. A negative | ||
| value indicates malformed input. | ||
| :param limit: the maximum total number of items permitted. | ||
| :raises avro.errors.AvroCollectionSizeException: if the block count is | ||
| negative or the running total would exceed ``limit``. |
There was a problem hiding this comment.
Fixed in b500066 — reworded the docstring: callers pass the normalized (non-negative) count, and a negative value there indicates an internal error rather than malformed wire data (negative block counts are valid in the Avro encoding).
| negative or the running total would exceed ``limit``. | ||
| """ | ||
| if items < 0: | ||
| raise avro.errors.AvroCollectionSizeException(f"Malformed data. Block count is negative: {items}") |
There was a problem hiding this comment.
Fixed in b500066 — the message is now "Block count must be normalized before checking, got: ...", reflecting that reaching this branch means an un-normalized count was passed rather than malformed input.
| def _default_max_collection_items() -> int: | ||
| """Return the default collection item limit, honoring the environment override.""" | ||
| value = os.environ.get(MAX_COLLECTION_ITEMS_ENV) | ||
| if value is None: | ||
| return DEFAULT_MAX_COLLECTION_ITEMS |
There was a problem hiding this comment.
Fixed in b500066 — added tests for the AVRO_MAX_COLLECTION_ITEMS override: a valid value is honored, invalid/negative/empty values fall back to the default, and a reader constructed with the env-set limit enforces it end to end.
… env tests - Reword the check_max_collection_items docstring and negative-count error: callers pass a normalized (non-negative) count, and a negative value there indicates an internal error, not malformed wire data (negative block counts are valid in the Avro encoding). - Add tests for the AVRO_MAX_COLLECTION_ITEMS override, including invalid and negative values falling back to the default and end-to-end enforcement. Assisted-by: GitHub Copilot:claude-opus-4.8
| if block_count < 0: | ||
| block_size = decoder.read_long() | ||
| decoder.skip(block_size) |
There was a problem hiding this comment.
Good catch — fixed in c0ba2d8. skip_array now validates block_size >= 0 before calling decoder.skip() and raises InvalidAvroBinaryEncoding otherwise, so a negative block size can no longer seek the decoder backwards. Added a test.
| if block_count < 0: | ||
| block_size = decoder.read_long() | ||
| decoder.skip(block_size) |
There was a problem hiding this comment.
Fixed in c0ba2d8 — same guard added to skip_map, with a test covering the negative block size case.
A negative block size read from untrusted input would make decoder.skip() seek backwards (BinaryDecoder.skip does not guard against negative n), corrupting decode state. Validate block_size >= 0 before skipping and raise InvalidAvroBinaryEncoding otherwise. Add tests. Assisted-by: GitHub Copilot:claude-opus-4.8
What is the purpose of the change
When decoding an array or map, the block count is read from the input and drives
allocation of the resulting collection. A very large or malformed block count
(for example from truncated or hostile input) could request an unbounded
allocation, since items such as
nulloccupy no bytes on the wire.This validates the block count — per block and cumulatively — against a
configurable maximum before allocating, mirroring the Java SDK's collection item
limit (
org.apache.avro.limits.collectionItems.maxLength, AVRO-3819). The limitdefaults to
2^31 - 8items and can be overridden with theAVRO_MAX_COLLECTION_ITEMSenvironment variable, or per reader viaDatumReader.max_collection_items. Exceeding it raisesavro.errors.AvroCollectionSizeException.This is part of the umbrella issue AVRO-4277.
Verifying this change
This change added tests and can be verified as follows:
TestCollectionSizeLimitinlang/py/avro/test/test_io.pycovering:cd lang/py && python3 -m unittest avro.test.test_ioDocumentation
AVRO_MAX_COLLECTION_ITEMSenvironment variable is documented in code comments)