-
Notifications
You must be signed in to change notification settings - Fork 1.8k
AVRO-4282: [python] Bound collection size when decoding arrays and maps #3845
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
193832c
eb6a031
71b1a43
5956bc6
fdfc426
b500066
c0ba2d8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -87,6 +87,7 @@ | |
| import collections | ||
| import datetime | ||
| import decimal | ||
| import os | ||
| import struct | ||
| import warnings | ||
| from typing import IO, Generator, Iterable, List, Mapping, Optional, Sequence, Union | ||
|
|
@@ -103,6 +104,56 @@ | |
| STRUCT_SIGNED_INT = struct.Struct(">i") # big-endian signed int | ||
| STRUCT_SIGNED_LONG = struct.Struct(">q") # big-endian signed long | ||
|
|
||
| # Name of the environment variable used to override the default maximum number | ||
| # of items permitted in a single decoded array or map. | ||
| MAX_COLLECTION_ITEMS_ENV = "AVRO_MAX_COLLECTION_ITEMS" | ||
|
|
||
| # Default upper bound on the number of items in a single array or map decoded | ||
| # from a stream. When decoding, the block count of an array or map is read from | ||
| # the (potentially untrusted or truncated) input and drives the allocation of | ||
| # the resulting collection. Reading a collection that declares more items than | ||
| # this limit raises an :class:`avro.errors.AvroCollectionSizeException` instead | ||
| # of attempting a potentially huge allocation. This mirrors the Java SDK's | ||
| # ``org.apache.avro.limits.collectionItems.maxLength`` limit. The default may be | ||
| # overridden with the ``AVRO_MAX_COLLECTION_ITEMS`` environment variable. | ||
| DEFAULT_MAX_COLLECTION_ITEMS = (1 << 31) - 8 | ||
|
|
||
|
|
||
| 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 | ||
| try: | ||
| parsed = int(value) | ||
| except ValueError: | ||
| warnings.warn(avro.errors.AvroWarning(f"Ignoring invalid {MAX_COLLECTION_ITEMS_ENV} value: {value!r}")) | ||
| return DEFAULT_MAX_COLLECTION_ITEMS | ||
| if parsed < 0: | ||
| warnings.warn(avro.errors.AvroWarning(f"Ignoring negative {MAX_COLLECTION_ITEMS_ENV} value: {value!r}")) | ||
| return DEFAULT_MAX_COLLECTION_ITEMS | ||
| return parsed | ||
|
|
||
|
|
||
| def check_max_collection_items(existing: int, items: int, limit: int) -> None: | ||
| """Guard against unbounded allocation when decoding an array or map. | ||
|
|
||
| :param existing: the number of items already read for the collection. | ||
| :param items: the number of items in the next block to be read. Callers pass | ||
| the normalized (non-negative) block count -- in the Avro | ||
| encoding a negative count merely signals that a block-size long | ||
| follows and its absolute value is the count. | ||
| :param limit: the maximum total number of items permitted. | ||
| :raises avro.errors.AvroCollectionSizeException: if the running total would | ||
| exceed ``limit`` (or, defensively, if ``items`` is negative, which would | ||
| indicate an un-normalized count was passed). | ||
| """ | ||
| if items < 0: | ||
| raise avro.errors.AvroCollectionSizeException(f"Block count must be normalized before checking, got: {items}") | ||
| if existing + items > limit: | ||
| raise avro.errors.AvroCollectionSizeException(f"Cannot read collections larger than {limit} items") | ||
|
|
||
|
|
||
| ValidationNode = collections.namedtuple("ValidationNode", ["schema", "datum", "name"]) | ||
| ValidationNodeGeneratorType = Generator[ValidationNode, None, None] | ||
| JsonScalarFieldType = Union[None, bool, str, int, float] | ||
|
|
@@ -613,6 +664,10 @@ def __init__(self, writers_schema: Optional[avro.schema.Schema] = None, readers_ | |
| """ | ||
| self._writers_schema = writers_schema | ||
| self._readers_schema = readers_schema | ||
| # Maximum number of items permitted in a single decoded array or map. | ||
| # Guards against unbounded allocation from a large block count read from | ||
| # untrusted or truncated input. See DEFAULT_MAX_COLLECTION_ITEMS. | ||
| self.max_collection_items: int = _default_max_collection_items() | ||
|
|
||
| @property | ||
| def writers_schema(self) -> Optional[avro.schema.Schema]: | ||
|
|
@@ -795,26 +850,35 @@ 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] = [] | ||
| block_count = decoder.read_long() | ||
| 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) | ||
|
Comment on lines
855
to
+859
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch — fixed in fdfc426. |
||
| 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: | ||
| block_count = decoder.read_long() | ||
| items_skipped = 0 | ||
| while block_count != 0: | ||
| if block_count < 0: | ||
| block_size = decoder.read_long() | ||
| if block_size < 0: | ||
| raise avro.errors.InvalidAvroBinaryEncoding(f"Array block size must be non-negative, got: {block_size}") | ||
| decoder.skip(block_size) | ||
| else: | ||
| # A negative block is skipped by byte size above; only the | ||
| # positive per-item skip loop can be driven unbounded by a large | ||
| # untrusted block count, so apply the same cumulative cap here. | ||
| check_max_collection_items(items_skipped, block_count, self.max_collection_items) | ||
| for i in range(block_count): | ||
| self.skip_data(writers_schema.items, decoder) | ||
| items_skipped += block_count | ||
| block_count = decoder.read_long() | ||
|
|
||
| def read_map(self, writers_schema: avro.schema.MapSchema, readers_schema: avro.schema.MapSchema, decoder: BinaryDecoder) -> Mapping[str, object]: | ||
|
|
@@ -834,26 +898,39 @@ def read_map(self, writers_schema: avro.schema.MapSchema, readers_schema: avro.s | |
| """ | ||
| read_items = {} | ||
| block_count = decoder.read_long() | ||
| # Track the number of pairs decoded rather than len(read_items): repeated | ||
| # keys collapse in the dict and would otherwise let the cumulative check | ||
| # be bypassed by a stream that keeps rewriting the same key. | ||
| items_read = 0 | ||
| 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) | ||
|
Comment on lines
905
to
+909
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in fdfc426 — |
||
| for i in range(block_count): | ||
| key = decoder.read_utf8() | ||
| read_items[key] = self.read_data(writers_schema.values, readers_schema.values, decoder) | ||
| items_read += block_count | ||
| block_count = decoder.read_long() | ||
| return read_items | ||
|
|
||
| def skip_map(self, writers_schema: avro.schema.MapSchema, decoder: BinaryDecoder) -> None: | ||
| block_count = decoder.read_long() | ||
| items_skipped = 0 | ||
| while block_count != 0: | ||
| if block_count < 0: | ||
| block_size = decoder.read_long() | ||
| if block_size < 0: | ||
| raise avro.errors.InvalidAvroBinaryEncoding(f"Map block size must be non-negative, got: {block_size}") | ||
| decoder.skip(block_size) | ||
| else: | ||
| # As in skip_array, only the positive per-item skip loop can be | ||
| # driven unbounded, so bound the cumulative count here too. | ||
| check_max_collection_items(items_skipped, block_count, self.max_collection_items) | ||
| for i in range(block_count): | ||
| decoder.skip_utf8() | ||
| self.skip_data(writers_schema.values, decoder) | ||
| items_skipped += block_count | ||
| block_count = decoder.read_long() | ||
|
|
||
| def read_union(self, writers_schema: avro.schema.UnionSchema, readers_schema: avro.schema.UnionSchema, decoder: BinaryDecoder) -> object: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.