AVRO-4296: [python] Bound allocation when decoding length-prefixed values and collections - #3861
AVRO-4296: [python] Bound allocation when decoding length-prefixed values and collections#3861iemejia wants to merge 33 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR hardens the Python Avro binary decoding path against malicious or truncated inputs that declare excessively large length/count prefixes, by validating declared sizes against bytes remaining for seekable inputs before allocating/iterating.
Changes:
- Add
BinaryDecoder.bytes_remaining()and use it to pre-reject oversizedread(n)requests (above a threshold) when the reader is seekable. - Add minimum on-wire-size estimation for schemas and use it to validate array/map block counts in
DatumReaderagainst remaining bytes. - Add unit tests covering oversized length prefixes, oversized collection block counts, and a non-false-positive case (array of
null).
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| lang/py/avro/io.py | Adds remaining-bytes introspection plus pre-checks for large length-prefixed reads and collection block count validation using per-element minimum sizes. |
| lang/py/avro/test/test_io.py | Adds targeted tests for the new available-bytes validation behavior in BinaryDecoder and DatumReader (including array-of-nulls). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
This PR now also includes the collection block-count cap for [python], so it is the single complete fix for collection allocation DoS in this SDK. In addition to validating available bytes before allocating length-prefixed values, it bounds the number of array/map items per block:
With this, the standalone collection-limit change for [python] (AVRO-4282, #3845) is redundant and is being closed as superseded by this PR. |
|
Preallocation (AVRO-4292 follow-up) evaluated — no change needed here. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
lang/py/avro/io.py:1032
read_map()useslen(read_items)as the "existing" element count for_ensure_collection_available(). Because maps can legally contain duplicate keys on the wire (which overwrite in the dict),len(read_items)can significantly undercount the number of key/value pairs actually decoded. This weakens the cumulative/structural cap and can allow an attacker to bypass the intended element-count limit by repeating keys across blocks.
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()
Review feedback: bytes_remaining() could leave the reader positioned at EOF if tell()/seek() failed after seeking to the end, corrupting subsequent decoding. Move the restore seek into a finally block so the original position is always restored. Added tests that the position is restored on both success and when reading the end offset fails. Assisted-by: GitHub Copilot:claude-opus-4.8
… dead test assignment Review feedback: - The finally block in bytes_remaining() only caught (OSError, ValueError) when restoring the position. A reader that implements tell() but not seek() would raise AttributeError from reader.seek(pos) and let it escape; catch AttributeError there too so the method reliably falls back to None. - Removed the unused self._calls assignment from the FailingEndStream test helper. Assisted-by: GitHub Copilot:claude-opus-4.8
Completes the available-bytes protection for collections and supersedes the
separate collection-limit change. 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. A tiny payload
declaring a huge array block count of such elements (e.g.
{"type":"array","items":"null"} with a count of 200,000,000) therefore drove an
unbounded list allocation and exhausted memory.
_ensure_collection_available now enforces, per block:
- the bytes-remaining check for elements with a positive on-wire minimum;
- a heap-independent cap on zero-byte elements (DEFAULT_MAX_COLLECTION_ITEMS =
10,000,000);
- a structural cap on all collections (DEFAULT_MAX_COLLECTION_STRUCTURAL =
Integer.MAX_VALUE - 8) as an overflow / defense-in-depth guard, covering
non-seekable readers where the bytes check cannot run.
AVRO_MAX_COLLECTION_ITEMS, when set, caps both limits. Applied to read_array,
read_map, skip_array and skip_map (cumulative across blocks, and after
normalizing a negative block count); maps are additionally bounded by their
>=1-byte keys. Raises the new AvroCollectionSizeException.
Assisted-by: GitHub Copilot:claude-opus-4.8
read_array/read_map now read the running length (len(read_items)) to enforce the collection limits before the first append/assignment, which left mypy unable to infer the element type of the empty list/dict from later usage (var-annotated). Annotate read_items explicitly (List[object] and Dict[str, object]) so mypy is satisfied; this is a typing-only change with no runtime effect. Assisted-by: GitHub Copilot:claude-opus-4.8
Unlike the fixed-width SDKs, Python integers do not overflow, so negating an INT64_MIN block count yields 2**63, which the existing zero-byte/structural cap already rejects. Add a regression test decoding the 10-byte INT64_MIN varint as an array<null> block count and asserting AvroCollectionSizeException, matching the negation-overflow coverage added to the C, C++ and C# SDKs. Assisted-by: GitHub Copilot:claude-opus-4.8
…ermetic Addresses review feedback: - skip_array/skip_map now normalize a negative block count and apply _ensure_collection_available (and the cumulative items_skipped) on both the sized and unsized paths, so a negative block count can no longer bypass the collection limits when skipping (e.g. during schema resolution). Adds a regression test. - test_collection_limits_env_caps_both no longer mutates the real process environment: the "unset" assertion now runs inside a patch.dict(os.environ) snapshot so a pre-set AVRO_MAX_COLLECTION_ITEMS is restored afterwards. Assisted-by: GitHub Copilot:claude-opus-4.8
read_enum, read_union and skip_union only checked the decoded index against the upper bound (index >= len(...)). Because Python list indexing wraps for negative values, a negative union/enum index silently selected the wrong branch/symbol instead of failing. Add a lower-bound check (index < 0) so a negative index is rejected with SchemaResolutionException too. Adds tests for negative and too-large union and enum indices. Assisted-by: GitHub Copilot:claude-opus-4.8
read_long looped over continuation bytes with no cap, so an overlong varint was accepted as an arbitrarily large Python integer (which then feeds length/skip calls) instead of being rejected. A 64-bit value uses at most 10 bytes; reject an 11th continuation byte with InvalidAvroBinaryEncoding, matching the Java, C and C++ SDKs. Adds a regression test. Assisted-by: GitHub Copilot:claude-opus-4.8
A non-standard file-like object can make tell()/seek() raise TypeError (e.g. a non-int position or seek offset). bytes_remaining() is best-effort, so treat TypeError like OSError/ValueError/AttributeError in both the size computation and the finally restore, returning None (unknown) instead of letting an unexpected exception break decoding. Assisted-by: GitHub Copilot:claude-opus-4.8
…keys read_map passed len(read_items) as the existing count to _ensure_collection_available, but that counts only unique keys. A map with duplicate keys (later entries overwrite earlier ones) undercounts the decoded pairs and can exceed the cumulative caps -- especially on non-seekable decoders where the bytes-remaining check can't run. Track a separate items_read counter (like skip_map) and use it for the limit check. Adds a duplicate-key regression test. Assisted-by: GitHub Copilot:claude-opus-4.8
…X_VALUE - 8 DEFAULT_MAX_COLLECTION_STRUCTURAL was (1 << 31) - 8 = 2147483640, but the documented and cross-SDK value is Integer.MAX_VALUE - 8 = 2147483639. Correct it to (1 << 31) - 1 - 8 so Python matches the other SDKs and its own docstring. Assisted-by: GitHub Copilot:claude-opus-4.8
…ange The overlong guard rejected >10-byte varints, but a 10-byte encoding can still carry bits beyond bit 63. The 10th byte (shift == 63) contributes only bit 63, so its higher payload bits must be clear for a valid 64-bit zig-zag long; reject the input otherwise. Assisted-by: GitHub Copilot:claude-opus-4.8
skip_array/skip_map passed the attacker-controlled block_size straight to decoder.skip(), so a negative value seeks backwards and an oversized value seeks past EOF, corrupting the decoder position. Add a _skip_block_bytes helper that rejects a negative size and one exceeding the bytes remaining before skipping. Assisted-by: GitHub Copilot:claude-opus-4.8
_min_bytes_per_element treated a union as a flat 1-byte minimum, but a union encodes a >= 1 byte branch index plus the selected branch's payload. When every branch has a positive minimum, this underestimated the true minimum and weakened the bytes-remaining guard. Return 1 + the smallest branch minimum (still 1 when any branch is null/zero-byte). Assisted-by: GitHub Copilot:claude-opus-4.8
A seekable stream positioned past its end could make bytes_remaining() return a
negative value ("only -N remain"), which is confusing and could reject valid
reads. Clamp end - pos to 0 so the method's contract stays non-negative.
Assisted-by: GitHub Copilot:claude-opus-4.8
…ent count _skip_block_bytes now also rejects a block_size too small to contain block_count elements at their minimum on-wire size (when min_bytes > 0), so a malformed sized block can't seek to a position mid-element and misalign the decoder. Assisted-by: GitHub Copilot:claude-opus-4.8
skip_long looped over continuation bytes with no cap, so skipping a long field (or the block-size long of a negative-count collection block) could scan an arbitrarily long malformed varint. Apply the same 10-byte limit read_long uses and raise InvalidAvroBinaryEncoding past it. Assisted-by: GitHub Copilot:claude-opus-4.8
skip_long() capped the varint at 10 bytes but did not reject a 10th byte carrying payload bits above bit 63, so malformed long encodings that read_long() rejects could be silently accepted when skipping (e.g. a negative-block byte-size skipped in read_array/read_map). Mirror read_long()'s check: track the shift and reject when shift == 63 and (b & 0x7E) != 0. Assisted-by: GitHub Copilot:claude-opus-4.8
…ages ruff format (>=0.15.1, as run by ./build.sh) collapses the two multi-line InvalidAvroBinaryEncoding raises in _skip_block_bytes onto single lines. Apply it so `./build.sh test` (which runs `ruff format --diff` first) passes in CI. Assisted-by: GitHub Copilot:claude-opus-4.8
…ypes when skipping
…reject negative skips
The #: attribute-doc syntax was not used anywhere else in the project and autodoc attribute docs are not rendered, so convert these comments (and the couple of :data: roles) to plain # comments for consistency.
skip() already rejects a negative byte count, so a negative length prefix falls through to it; keep skip() as the single backward-seek guard.
Only pay for the per-block bytes_remaining() seek (tell + seek-to-end + seek-back) when a block count exceeds _MAX_UNCHECKED_COLLECTION, mirroring _MAX_UNCHECKED_READ in read(): a small block of positive-size elements must be backed by real bytes on the wire and cannot over-allocate meaningfully. The structural cap stays unconditional and the zero-byte path is unchanged, so the bound is not weakened. Also document that AVRO_MAX_COLLECTION_ITEMS pins both the zero-byte and structural limits to the same value.
67e4cbe to
77ae5d2
Compare
What is the purpose of the change
A
bytesorstringvalue is encoded as a length prefix followed by that many bytes of data, and anarrayormapblock is encoded as an element count followed by that many items. A malicious or truncated input can declare a very large length or count while carrying little or no actual data, which causes a correspondingly large allocation before the shortfall is noticed.This applies the equivalent of the Java SDK fix AVRO-4241 to the Python SDK and extends it to collections. It has two complementary parts.
1. Validate available bytes before allocating
When the source can report how many bytes remain, a declared length (or a collection block count) that exceeds the bytes actually available is rejected before allocating for it. The collection check uses the minimum on-wire size of the element schema, so a zero-byte element type (such as
null) is never falsely rejected. Sources that cannot report their remaining size are unaffected.BinaryDecoder.bytes_remaining()reports the bytes still readable for a seekable reader (elseNone).read()rejects an over-large declared length above a threshold, andDatumReader.read_array/read_mapreject a block whose element count could not be backed by the bytes remaining, usingmin_bytes_per_element()from the element schema.2. Cap collection allocation for zero-byte elements
Zero-byte elements (
null, a zero-lengthfixed, or a record with only zero-byte fields) consume no input, so the available-bytes check cannot bound their count: a tiny payload such as{{"type":"array","items":"null"}}declaring a block count of 200,000,000 would otherwise drive an unbounded allocation. In addition to the available-bytes check,_ensure_collection_availablecaps the cumulative count of zero-byte elements (DEFAULT_MAX_COLLECTION_ITEMS= 10,000,000) and applies a structural cap to every collection (DEFAULT_MAX_COLLECTION_STRUCTURAL=Integer.MAX_VALUE - 8) covering non-seekable readers. It is applied toread_array,read_map,skip_arrayandskip_map(cumulative across blocks, and after normalizing a negative block count); maps are additionally bounded by their >=1-byte keys. Rejections raise the newAvroCollectionSizeException. When set, theAVRO_MAX_COLLECTION_ITEMSenvironment variable caps both limits.This folds in and supersedes the standalone collection-limit change (AVRO-4282, #3845), so this PR is the single complete fix for collection/length-prefixed allocation DoS in the Python SDK.
This is a sub-task of AVRO-4292 and resolves AVRO-4296.
Verifying this change
This change added tests and can be verified as follows:
TestBinaryDecoderAvailableBytes,TestDatumReaderCollectionAvailableBytesand the zero-byte cap tests inlang/py/avro/test/test_io.py, including anarray<null>with a huge block count that must be rejected and a small one that still decodes.cd lang/py && python3 -m unittest avro.test.test_ioDocumentation