Skip to content

AVRO-4296: [python] Bound allocation when decoding length-prefixed values and collections - #3861

Open
iemejia wants to merge 33 commits into
apache:mainfrom
iemejia:AVRO-4296-python-available-bytes
Open

AVRO-4296: [python] Bound allocation when decoding length-prefixed values and collections#3861
iemejia wants to merge 33 commits into
apache:mainfrom
iemejia:AVRO-4296-python-available-bytes

Conversation

@iemejia

@iemejia iemejia commented Jul 11, 2026

Copy link
Copy Markdown
Member

What is the purpose of the change

A bytes or string value is encoded as a length prefix followed by that many bytes of data, and an array or map block 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 (else None). read() rejects an over-large declared length above a threshold, and DatumReader.read_array/read_map reject a block whose element count could not be backed by the bytes remaining, using min_bytes_per_element() from the element schema.

2. Cap collection allocation for zero-byte elements

Zero-byte elements (null, a zero-length fixed, 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_available caps 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 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. Rejections raise the new AvroCollectionSizeException. When set, the AVRO_MAX_COLLECTION_ITEMS environment 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:

  • Added TestBinaryDecoderAvailableBytes, TestDatumReaderCollectionAvailableBytes and the zero-byte cap tests in lang/py/avro/test/test_io.py, including an array<null> with a huge block count that must be rejected and a small one that still decodes.
  • Run: cd lang/py && python3 -m unittest avro.test.test_io

Documentation

  • Does this pull request introduce a new feature? (no — hardening / robustness)
  • If yes, how is the feature documented? (not applicable)

Comment thread lang/py/avro/io.py Fixed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 oversized read(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 DatumReader against 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.

Comment thread lang/py/avro/io.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

Comment thread lang/py/avro/io.py Outdated
Comment thread lang/py/avro/io.py
Comment thread lang/py/avro/io.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Comment thread lang/py/avro/io.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

Comment thread lang/py/avro/io.py Outdated
Comment thread lang/py/avro/test/test_io.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

@iemejia

iemejia commented Jul 12, 2026

Copy link
Copy Markdown
Member Author

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:

  • a heap-aware cap for zero-byte-element collections (e.g. array<null>), which otherwise bypass the available-bytes check because each element reads 0 bytes;
  • a structural cap for all collections;
  • bounded skip paths so projection/skip cannot loop unboundedly.

With this, the standalone collection-limit change for [python] (AVRO-4282, #3845) is redundant and is being closed as superseded by this PR.

@iemejia iemejia changed the title AVRO-4296: [python] Validate available bytes before allocating for length-prefixed values AVRO-4296: [python] Bound allocation when decoding length-prefixed values and collections Jul 12, 2026
@iemejia
iemejia requested a review from Copilot July 12, 2026 15:34

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

Comment thread lang/py/avro/io.py Outdated
Comment thread lang/py/avro/io.py Outdated
Comment thread lang/py/avro/test/test_io.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

@iemejia

iemejia commented Jul 12, 2026

Copy link
Copy Markdown
Member Author

Preallocation (AVRO-4292 follow-up) evaluated — no change needed here. read_array/read_map build read_items/the dict via .append(), growing on demand rather than preallocating to the declared block count, so there is no up-front over-allocation to bound (unlike the Java/C++/C# readers, which sized a buffer to the count). The existing structural and zero-byte caps already bound the growth.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() uses len(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()

Comment thread lang/py/avro/io.py
Comment thread lang/py/avro/io.py
iemejia added 29 commits August 5, 2026 23:36
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
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.
@iemejia
iemejia force-pushed the AVRO-4296-python-available-bytes branch from 67e4cbe to 77ae5d2 Compare August 5, 2026 21:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants