Skip to content

AVRO-4296: [python] Validate available bytes before allocating for length-prefixed values#3861

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

AVRO-4296: [python] Validate available bytes before allocating for length-prefixed values#3861
iemejia wants to merge 6 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: 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.

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 and TestDatumReaderCollectionAvailableBytes in lang/py/avro/test/test_io.py, including an array of nulls that must not be falsely rejected.
  • 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)

…ngth-prefixed values and collections

A bytes or string value is a length prefix followed by that many bytes, and an
array or map block is an element count followed by that many items. A malicious
or truncated input can declare a huge length or count with little or no data.

- BinaryDecoder.bytes_remaining() reports the bytes still readable for a
  seekable reader (else None). read() uses it to reject an over-large declared
  length above a threshold before allocating.
- DatumReader.read_array/read_map reject a block whose element count could not
  be backed by the bytes remaining, using min_bytes_per_element() computed from
  the element schema so a zero-byte element type (e.g. null) is not falsely
  rejected.

Mirrors the Java SDK's checks (AVRO-4241). Non-seekable readers, whose
remaining length is unknown, are unaffected.

Assisted-by: GitHub Copilot:claude-opus-4.8
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
Comment on lines +854 to +858
remaining = decoder.bytes_remaining()
if remaining is not None and count * min_bytes_per_element > remaining:
raise avro.errors.InvalidAvroBinaryEncoding(
f"Collection claims {count} elements with at least {min_bytes_per_element} bytes each, but only {remaining} bytes are available."
)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 957d180: _ensure_collection_available now compares count against remaining // min_bytes_per_element instead of multiplying, avoiding a large intermediate product for an attacker-controlled count.

Review feedback:
- avro/io.py imported the standard library 'io' module, which CodeQL flags as a
  module importing itself. Import 'os' and use os.SEEK_END instead (both equal 2).
- _ensure_collection_available compares count against remaining // min bytes per
  element rather than multiplying, so an attacker-controlled (unbounded) count
  does not create a huge intermediate product.

Assisted-by: GitHub Copilot:claude-opus-4.8

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 on lines +241 to +247
reader = self.reader
if not getattr(reader, "seekable", None) or not reader.seekable():
return None
pos = reader.tell()
end = reader.seek(0, os.SEEK_END)
reader.seek(pos)
return end - pos

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in f415f1f: bytes_remaining() now wraps tell()/seek() in try/except (catching OSError/ValueError/AttributeError), uses tell() for the end offset, and validates both are ints, returning None on any failure.

Comment thread lang/py/avro/io.py
Comment on lines 883 to 885
decoder.skip_long()
self._ensure_collection_available(decoder, block_count, _min_bytes_per_element(writers_schema.items))
for i in range(block_count):

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in f415f1f: read_array computes the per-element minimum once before the block loop.

Comment thread lang/py/avro/io.py Outdated
Comment on lines 922 to 924
# Map keys are strings (>= 1 byte length prefix) plus the value.
self._ensure_collection_available(decoder, block_count, 1 + _min_bytes_per_element(writers_schema.values))
for i in range(block_count):

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in f415f1f: read_map computes 1 + min-bytes once before the block loop.

iemejia added 2 commits July 12, 2026 01:01
…n minimum once

Review feedback:
- bytes_remaining() now wraps tell()/seek() in try/except and uses tell() for the
  end offset, returning None on any failure (non-seekable reader, seek() that
  returns None, or a reader without seekable()). This keeps reads unaffected when
  the remaining size cannot be determined.
- read_array/read_map compute the per-element minimum once before the block loop
  instead of on every block, avoiding repeated schema traversal.

Assisted-by: GitHub Copilot:claude-opus-4.8
The Typechecks CI step (mypy) flagged the isinstance(pos/end, int) check as
unreachable: reader is typed IO[bytes], so tell() already returns int, making
the guard always true and the following return None unreachable. The try/except
around tell()/seek() already provides the resilience the check was meant to add,
so remove the redundant guard.

Assisted-by: GitHub Copilot:claude-opus-4.8

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
Comment on lines +242 to +251
reader = self.reader
try:
pos = reader.tell()
reader.seek(0, os.SEEK_END)
end = reader.tell()
reader.seek(pos)
except (OSError, ValueError, AttributeError):
# Not seekable, or the position/size could not be determined.
return None
return end - pos

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in b891ac3: bytes_remaining() now restores the original position in a finally block, so a failure after seeking to the end never leaves the reader at EOF. Added tests for the restore on both success and failure. (The isinstance type-validation was dropped because reader is typed IO[bytes], so tell() returns int and mypy flags the check as unreachable; the try/except covers the runtime failure paths.)

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

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 on lines +257 to +259
try:
reader.seek(pos)
except (OSError, ValueError):

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 1020612: the finally block now also catches AttributeError when restoring the position, so a reader that implements tell() but not seek() reliably falls back to None instead of raising.

Comment thread lang/py/avro/test/test_io.py Outdated
Comment on lines +593 to +596
def __init__(self, data: bytes) -> None:
super().__init__(data)
self._calls = 0

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Removed the unused self._calls assignment from the FailingEndStream test helper in 1020612.

… 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

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.

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.

3 participants