fix: bound array/map allocation by decoded element count#581
Conversation
An array or map block is encoded as an element count followed by that many items. When decoding, `items.reserve(len)` allocates `len * size_of::<Value>()` bytes (Value is 56 bytes), but the count was only validated with `safe_len`, which bounds it as if it were a byte count. So a tiny payload declaring a huge block count could drive a multi-gigabyte allocation far exceeding the configured `max_allocation_bytes` budget (up to ~28 GiB at the safe_len ceiling), and a count of zero-byte on-wire elements (e.g. `null`) could grow the collection unboundedly since such elements consume no input. Add `util::safe_collection_len`, which bounds the cumulative element count against the allocation budget accounting for the in-memory size of each element, and apply it in the array and map decode loops before reserving. This caps both the up-front reservation and the total number of decoded elements, covering zero-byte and non-zero-byte element types alike. The limit stays configurable through `max_allocation_bytes`. Assisted-by: GitHub Copilot:claude-opus-4.8
There was a problem hiding this comment.
Pull request overview
This PR hardens Avro decoding against maliciously large array/map block counts that could trigger excessive in-memory allocations (especially via reserve(len)), by bounding decoded element counts against the configured allocation budget.
Changes:
- Add
util::safe_collection_len(total_items, item_size)to enforce an allocation-budget cap for cumulative decoded collection sizes (arrays/maps). - Apply the new guard in
Schema::ArrayandSchema::Mapdecode loops before callingreserve. - Add decode tests ensuring huge array/map counts are rejected while small valid inputs still decode.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| avro/src/util.rs | Introduces safe_collection_len to bound collection element-count driven allocations against max_allocation_bytes. |
| avro/src/decode.rs | Uses safe_collection_len in array/map decoding prior to reserve, and adds regression tests for huge block counts. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
decode_seq_len already rejects a block count whose absolute value is i64::MIN via checked_neg (which returns None, yielding Details::IntegerOverflow), so no code change is needed. Add a regression test decoding the 10-byte i64::MIN varint as a negative block count and asserting it is rejected, for parity with the negation-overflow coverage in the other Avro SDKs. Assisted-by: GitHub Copilot:claude-opus-4.8
- safe_collection_len: use checked_mul instead of saturating_mul so a usize::MAX allocation budget cannot mask a multiplication overflow (which would let reserve() hit a capacity-overflow panic); return Details::IntegerOverflow on overflow. - tests: build block-count varints via the crate's zig_i64 encoder rather than a duplicated zigzag/varint implementation. Assisted-by: GitHub Copilot:claude-opus-4.8
| .checked_add(len) | ||
| .ok_or(Details::IntegerOverflow)?; | ||
| safe_collection_len(total, std::mem::size_of::<Value>())?; | ||
| items.reserve(len); |
There was a problem hiding this comment.
Fixed in 2aa1ba1: array decoding now uses reserve_exact(len), so the reservation matches the checked element count rather than reserve's amortized (larger) capacity.
| /// [`safe_len`] guards byte-length allocations, but an array or map block is a | ||
| /// count of elements, and reserving capacity for `n` elements allocates | ||
| /// `n * item_size` bytes (`item_size` being the in-memory size of a decoded | ||
| /// element). A malicious or truncated input can declare a huge block count in a |
There was a problem hiding this comment.
Fixed in 2aa1ba1: the doc now says reserving allocates at least n * item_size bytes and notes a collection may over-allocate for growth/metadata, so it's a lower-bound estimate.
…stimate Addresses review: - Array decoding uses reserve_exact so the reservation matches the checked element count, rather than reserve's amortized (larger) capacity which could request more than max_allocation_bytes across multiple blocks. - Clarify in the safe_collection_len doc and the map decode comment that the per-element size is a lower-bound estimate: HashMap::reserve (and Vec growth) can over-allocate for metadata/spare capacity, so the budget is enforced approximately for maps (there is no HashMap::reserve_exact). Assisted-by: GitHub Copilot:claude-opus-4.8
Document that safe_collection_len uses item_size.max(1), so a zero-sized element type is treated as one byte and the reported `desired` allocation is total_items * max(item_size, 1). This matches the implementation and clarifies the error's `desired` interpretation. Assisted-by: GitHub Copilot:claude-opus-4.8
What
An
arrayormapblock is encoded as an element count followed by that many items. When decoding,items.reserve(len)allocateslen * size_of::<Value>()bytes (Valueis 56 bytes), but the count was only validated withsafe_len, which bounds it as if it were a byte count.As a result a tiny payload declaring a huge block count could drive a multi-gigabyte allocation far exceeding the configured
max_allocation_bytesbudget. At thesafe_lenceiling this reaches ~28 GiB. Reproduced with a 5-byte payload (array<null>, block count10_000_000) decoding into a 10M-element array (534 MiB) — exceeding the default 512 MiB budget while being accepted.This affects:
null) and non-zero-byte elements — thereservehappens before any element is read;How
util::safe_collection_len(total_items, item_size), which bounds the cumulative element count againstmax_allocation_bytes, accounting for the in-memory size of each decoded element (mirroringsafe_len, which only covers byte-length allocations).Schema::ArrayandSchema::Mapdecode loops beforereserve, usingsize_of::<Value>()andsize_of::<(String, Value)>()respectively, with achecked_addguard for the running total.This caps both the up-front reservation and the total number of decoded elements. The limit stays configurable through
max_allocation_bytes(the default 512 MiB budget yields ~9.6MValues, close to the item caps used by the other Avro SDKs).bytes/stringlength-prefixed values are already bounded bysafe_len; sincedecodeoperates on a bareReadwith no known remaining size, that budget cap is the appropriate guard (equivalent to the non-seekable path in the other SDKs).Tests
array<null>,array<long>, andmapblock counts are rejected instead of allocating; a smallarray<null>still decodes.cargo test -p apache-avro(564 lib tests) passes;cargo fmt --checkandcargo clippyare clean.