AVRO-4296: [python] Bound allocation when decoding length-prefixed values and collections - #3861
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()
|
|
||
| _reader: IO[bytes] | ||
|
|
||
| #: Reads with a declared length above this many bytes are validated against |
There was a problem hiding this comment.
Just curious -- what is the #: for? We don't use this anywhere else in the project.
There was a problem hiding this comment.
Good catch — #: is Sphinx/autodoc syntax that documents the attribute on the following line, but you're right the project doesn't use it anywhere else and doesn't render autodoc attribute docs, so it was just inconsistent. Switched all of these to plain # comments (and dropped the couple of :data: roles for the same reason).
| if n < 0: | ||
| raise avro.errors.InvalidAvroBinaryEncoding(f"Requested {n} bytes to skip, expected positive integer.") |
There was a problem hiding this comment.
Redundant with skip(n) also checking negative bytes -- remove or align error messages?
There was a problem hiding this comment.
Removed the redundant check — a negative length now falls through to skip(), which already rejects backward seeks, so there's a single enforcement point. (read() keeps its own check since it doesn't delegate to skip().)
|
Took a pass over this since it supersedes my #3876. The enum/union index hardening here is a superset of what I had, so #3876 is closed. Checked out the branch,
One thing worth considering before merge. On
if min_bytes_per_element > 0:
if count > _MAX_UNCHECKED_COLLECTION: # e.g. 1024
remaining = decoder.bytes_remaining()
if remaining is not None and count > remaining // min_bytes_per_element:
raise ...
if existing + count > structural_limit: # keep unconditional, no seek
raise ...I tried that locally: back to 0.074s, and both attack payloads above are still rejected. It does not weaken the bound either, since with Minor, take it or leave it: Neither is a blocker. LGTM otherwise. |
|
Thanks for the detailed benchmarking, @arib06. Applied both suggestions:
|
67e4cbe to
77ae5d2
Compare
…lues and collections When decoding an array or map, DatumReader.read_array/read_map used the block count read from the (potentially malformed or truncated) input directly as a loop counter, and the length-prefixed byte/string readers allocated from an unchecked length. A small payload could therefore declare a very large count or length and drive an unbounded allocation before any element bytes were present. This bounds those allocations, mirroring the Java SDK's two-limit approach: - Length-prefixed values and collection blocks are validated against the number of bytes actually remaining in the input (when the reader is seekable) before allocating, so a truncated payload fails fast instead of over-allocating. - Element types whose minimum encoded size is zero (null, a zero-length fixed, or a record whose fields are all zero-byte) cannot be bounded by the bytes remaining, so their cumulative count is capped by a separate configurable limit (AVRO_MAX_COLLECTION_ITEMS). A structural cap of Integer.MAX_VALUE - 8 applies to all collections as defense in depth. It also hardens related decode paths surfaced by this work: overlong/overflowing varints are rejected, negative skips and out-of-range union/enum indices are rejected, sized-block skips validate their byte size, and bytes_remaining() restores the reader position and degrades gracefully on non-seekable readers. Reading malformed or truncated input now fails fast with a clear AvroCollectionSizeException or InvalidAvroBinaryEncoding; valid data reads unchanged.
77ae5d2 to
3dbcb43
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (1)
lang/py/avro/io.py:470
skip_bytes()only validates oversized lengths whenbytes_remaining()returns a non-Nonevalue. Howeverbytes_remaining()can returnNoneeven for a reader that still supportstell()/seek()(e.g. ifseek(0, SEEK_END)fails as in theFailingEndStreamtest helper). In that case an attacker-controlled large length prefix can be skipped viaseek()past EOF without error; if the skipped bytes/string field is the final field being skipped (e.g. during schema resolution), truncated input may be accepted instead of failing fast.
n = self.read_long()
if n > self._MAX_UNCHECKED_READ:
remaining = self.bytes_remaining()
if remaining is not None and n > remaining:
raise avro.errors.InvalidAvroBinaryEncoding(f"Requested {n} bytes to skip, but only {remaining} remain.")
|
Cherry-picked to [branch-1.12|https://github.com/apache/avro/commit/c702676903c38680171f41fa95d4e5c56d01efff]. |
…lues and collections (#3861) When decoding an array or map, DatumReader.read_array/read_map used the block count read from the (potentially malformed or truncated) input directly as a loop counter, and the length-prefixed byte/string readers allocated from an unchecked length. A small payload could therefore declare a very large count or length and drive an unbounded allocation before any element bytes were present. This bounds those allocations, mirroring the Java SDK's two-limit approach: - Length-prefixed values and collection blocks are validated against the number of bytes actually remaining in the input (when the reader is seekable) before allocating, so a truncated payload fails fast instead of over-allocating. - Element types whose minimum encoded size is zero (null, a zero-length fixed, or a record whose fields are all zero-byte) cannot be bounded by the bytes remaining, so their cumulative count is capped by a separate configurable limit (AVRO_MAX_COLLECTION_ITEMS). A structural cap of Integer.MAX_VALUE - 8 applies to all collections as defense in depth. It also hardens related decode paths surfaced by this work: overlong/overflowing varints are rejected, negative skips and out-of-range union/enum indices are rejected, sized-block skips validate their byte size, and bytes_remaining() restores the reader position and degrades gracefully on non-seekable readers. Reading malformed or truncated input now fails fast with a clear AvroCollectionSizeException or InvalidAvroBinaryEncoding; valid data reads unchanged.
What changes were proposed in this pull request?
When decoding an array or map,
DatumReader.read_array/read_mapused the block count read from the (potentially malformed or truncated) input directly as a loop counter, and the length-prefixed byte/string readers allocated from an unchecked length. A small payload could therefore declare a very large count or length and drive an unbounded allocation before any element bytes were present.This bounds those allocations, mirroring the Java SDK's two-limit approach:
null, a zero-lengthfixed, or a record whose fields are all zero-byte) cannot be bounded by the bytes remaining, so their cumulative count is capped by a separate configurable limit (AVRO_MAX_COLLECTION_ITEMS). A structural cap ofInteger.MAX_VALUE - 8applies to all collections as defense in depth.It also hardens related decode paths surfaced by this work:
read_long/skip_long;bytes_remaining()always restores the reader position and degrades gracefully on non-seekable readers.Reading malformed or truncated input now fails fast with a clear
AvroCollectionSizeExceptionorInvalidAvroBinaryEncoding; valid data reads unchanged.How was this patch tested?
avro/test/test_io.pycovering: array/map counts beyond the bytes remaining, the zero-byte-element cap (includingnull, zero-lengthfixed, all-zero-byte records, cumulative counts across blocks, negative andINT64_MINblock counts), theAVRO_MAX_COLLECTION_ITEMSoverride (valid/invalid/negative), varint bounds, sized-block skip validation, and non-seekable readers.null) continues to round-trip unchanged, and the existing enum-defaultforward-compatibility behavior is preserved.avroPython test suite passes across supported interpreters.JIRA