Skip to content

AVRO-4297: [ruby] Bound allocation when decoding length-prefixed values and collections#3862

Open
iemejia wants to merge 18 commits into
apache:mainfrom
iemejia:AVRO-4297-ruby-available-bytes
Open

AVRO-4297: [ruby] Bound allocation when decoding length-prefixed values and collections#3862
iemejia wants to merge 18 commits into
apache:mainfrom
iemejia:AVRO-4297-ruby-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 Ruby 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 when the reader can report its size (else nil). #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, 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 tracks the cumulative count across blocks and applies a structural cap to every collection (DEFAULT_MAX_COLLECTION_STRUCTURAL = Integer.MAX_VALUE - 8, also covering readers that cannot report their size) and a zero-byte item cap (DEFAULT_MAX_COLLECTION_ITEMS = 10,000,000). read_array/read_map and the skip path (skip_blocks, used by skip_array/skip_map) are all bounded the same way. Rejections raise the dedicated Avro::IO::CollectionSizeError (a subclass of AvroError). When set, the AVRO_MAX_COLLECTION_ITEMS environment variable caps both limits.

This is a sub-task of AVRO-4292 and resolves AVRO-4297.

Verifying this change

This change added tests and can be verified as follows:

  • Extended lang/ruby/test/test_io.rb with over-limit bytes/array/map rejection, an array<null> huge-count rejection, a small array<null> that still decodes, and the skip path bounded.
  • Run: cd lang/ruby && bundle exec rake test (202 tests passing); bundle exec rubocop lib/avro/io.rb (clean).

Documentation

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

…th-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 when the reader
  can report its size (else nil). #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). Readers that cannot report their size
are unaffected.

Assisted-by: GitHub Copilot:claude-opus-4.8
@github-actions github-actions Bot added the Ruby label Jul 11, 2026
The Lint CI step (rubocop) flagged three offenses in the new code:
- Lint/IneffectiveAccessModifier: 'private' does not apply to a 'def self.'
  singleton method. Make min_bytes_per_element a private instance method (it
  uses no class state) and call it unqualified.
- Lint/HashCompareByIdentity: use a hash with compare_by_identity and the
  schema object as the key instead of keying on schema.object_id.

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

This PR hardens the Ruby Avro binary decoding path against malicious/truncated inputs that declare huge length/count prefixes by validating declared sizes against bytes remaining (when the underlying reader can report its size) before performing large allocations.

Changes:

  • Added BinaryDecoder#bytes_remaining and a thresholded pre-allocation check in BinaryDecoder#read for large length-prefixed reads.
  • Added array/map block pre-validation in DatumReader#read_array and #read_map using a computed minimum on-wire size per element schema.
  • Added Ruby tests covering over-limit bytes/array/map rejections and ensuring arrays of null are not falsely rejected.

Reviewed changes

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

File Description
lang/ruby/lib/avro/io.rb Adds bytes-remaining reporting and enforces length/count sanity checks to prevent oversized allocations during decoding.
lang/ruby/test/test_io.rb Adds regression tests for rejecting over-declared lengths/counts while preserving valid decoding behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lang/ruby/lib/avro/io.rb
Comment on lines 112 to +116
def read(len)
# Read n bytes
# Read n bytes. Reject a declared length that exceeds the bytes
# actually remaining before allocating for it, to guard against an
# out-of-memory attack from a malicious or truncated input. The check
# is only applied to larger reads; smaller reads and stream readers that

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 d4eec59: BinaryDecoder#read now rejects a negative length (Ruby IO#read(-1) reads the rest of the stream, which would bypass the size check).

Comment thread lang/ruby/lib/avro/io.rb Outdated
when :float then 4
when :double then 8
when :fixed then schema.size
when :record, :error

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 d4eec59: min_bytes_per_element now treats :request like :record/:error, so an array/map of request values whose fields encode to zero bytes is not falsely rejected.

…cord

Review feedback:
- BinaryDecoder#read now rejects a negative length. Ruby IO#read(-1) reads the
  rest of the stream, which would bypass the size check and allocate without
  bound.
- min_bytes_per_element treats :request like :record/:error (request schemas are
  record-like), so an array/map of request values whose fields encode to zero
  bytes is not falsely rejected.

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/ruby/lib/avro/io.rb
Comment on lines +135 to +138
def bytes_remaining
return nil unless @reader.respond_to?(:size) && @reader.respond_to?(:tell)
@reader.size - @reader.tell
end

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 34859b4: bytes_remaining now checks that #size/#tell return Integers and rescues IOError/SystemCallError/NotImplementedError, returning nil when the size cannot be reported.

Comment thread lang/ruby/lib/avro/io.rb Outdated
Comment on lines +562 to +566
return if count <= 0 || min_bytes_per_element <= 0
remaining = decoder.bytes_remaining
if remaining && count * min_bytes_per_element > remaining
raise AvroError, "Collection claims #{count} elements with at least #{min_bytes_per_element} bytes each, but only #{remaining} bytes are available"
end

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 34859b4: ensure_collection_available compares count against remaining / min_bytes_per_element instead of multiplying, avoiding a large intermediate Integer.

Review feedback:
- bytes_remaining now verifies #size/#tell return Integers and rescues
  IOError/SystemCallError/NotImplementedError, returning nil when the remaining
  size cannot be reported, so reads stay unaffected in that case.
- ensure_collection_available compares count against remaining /
  min_bytes_per_element rather than multiplying, avoiding a large intermediate
  Integer for an attacker-controlled count.

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/ruby/lib/avro/io.rb Outdated
Comment on lines +137 to +140
size = @reader.size
pos = @reader.tell
return nil unless size.is_a?(Integer) && pos.is_a?(Integer)
size - 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 ea1ac32: bytes_remaining clamps a negative result to 0 via [size - pos, 0].max, so a truncated file yields 'no bytes available' instead of a negative count.

Comment thread lang/ruby/lib/avro/io.rb Outdated
block_count = -block_count
_block_size = decoder.read_long
end
ensure_collection_available(decoder, block_count, min_bytes_per_element(writers_schema.items))

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 ea1ac32: read_array computes the per-element minimum once per invocation and reuses it across blocks.

Comment thread lang/ruby/lib/avro/io.rb Outdated
_block_size = decoder.read_long
end
# Map keys are strings (>= 1 byte length prefix) plus the value.
ensure_collection_available(decoder, block_count, 1 + min_bytes_per_element(writers_schema.values))

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 ea1ac32: read_map computes 1 + min-bytes once per invocation and reuses it across blocks.

…nimum

Review feedback:
- bytes_remaining clamps a negative result (size smaller than the current
  position, e.g. a truncated file) to 0 with [size - pos, 0].max, so callers see
  no bytes available rather than a confusing negative count.
- read_array/read_map compute the per-element minimum once per invocation and
  reuse it across blocks, avoiding repeated schema traversal on the hot path.

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/ruby/lib/avro/io.rb Outdated
Comment on lines +573 to +581
def ensure_collection_available(decoder, count, min_bytes_per_element)
return if count <= 0 || min_bytes_per_element <= 0
remaining = decoder.bytes_remaining
# Compare via integer division rather than multiplying, so a huge count
# does not create a large intermediate product.
if remaining && count > remaining / min_bytes_per_element
raise AvroError, "Collection claims #{count} elements with at least #{min_bytes_per_element} bytes each, but only #{remaining} bytes are available"
end
end

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 1186853: ensure_collection_available now guards the call with respond_to?(:bytes_remaining) and skips the check for a decoder that does not implement it, instead of raising NoMethodError.

…s_remaining

Review feedback: a decoder that implements the read protocol but not
#bytes_remaining (e.g. a custom decoder) would raise NoMethodError. Guard the
call with respond_to?(:bytes_remaining) and skip the check for such a decoder
instead of raising.

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/ruby/test/test_io.rb Outdated
end

def test_read_bytes_within_stream_still_reads
payload = 'x' * (2 * 1024 * 1024)

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 1a3d3da: the payload is now 'x' * (Avro::IO::BinaryDecoder::MAX_UNCHECKED_READ + 1), so it still exercises the large-read path but with less time and memory.

Comment thread lang/ruby/test/test_io.rb Outdated
# report its size, that is rejected before allocating for it.
def test_read_bytes_rejects_length_beyond_stream
writer = StringIO.new
Avro::IO::BinaryEncoder.new(writer).write_long(100 * 1024 * 1024)

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 1a3d3da: the declared length is now Avro::IO::BinaryDecoder::MAX_UNCHECKED_READ + 1, tying the test directly to the threshold under test instead of a hard-coded 100MiB constant.

Comment thread lang/ruby/test/test_io.rb
Comment on lines +93 to +100
count = 100_000
writer = StringIO.new
encoder = Avro::IO::BinaryEncoder.new(writer)
encoder.write_long(count) # one block of `count` nulls (zero bytes each)
encoder.write_long(0) # end-of-array marker
result = decode('{"type":"array","items":"null"}', writer.string)
assert_equal([nil] * count, result)
end

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 1a3d3da: the test now asserts the decoded length and spot-checks the first and last elements are nil, instead of building a second large [nil] * count array.

… null assertion

Review feedback:
- Derive the oversized declared length and the within-limit payload from
  BinaryDecoder::MAX_UNCHECKED_READ + 1 instead of hard-coded 100MiB/2MiB
  constants, so the tests stay tied to the threshold under test and use less
  time and memory.
- Assert the decoded array length and spot-check a couple of nil elements
  instead of building a second large [nil] * count array.

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.

Completes the available-bytes protection for collections. Elements whose schema
encodes to zero bytes (null, 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 allocation.

ensure_collection_available now tracks the cumulative count across blocks and
enforces, per block: a structural cap on all collections
(DEFAULT_MAX_COLLECTION_STRUCTURAL = Integer.MAX_VALUE - 8, also covering
readers that cannot report their remaining bytes); a zero-byte item cap
(DEFAULT_MAX_COLLECTION_ITEMS = 10,000,000) when the per-element minimum is
zero; and the existing bytes-remaining check otherwise. AVRO_MAX_COLLECTION_ITEMS,
when set, caps both. read_array/read_map and the skip path (skip_blocks, used by
skip_array/skip_map) are all bounded the same way, so skipping a huge zero-byte
block cannot loop unboundedly. Rejections raise the dedicated
Avro::IO::CollectionSizeError (a subclass of AvroError).

Assisted-by: GitHub Copilot:claude-opus-4.8
@iemejia iemejia changed the title AVRO-4297: [ruby] Validate available bytes before allocating for length-prefixed values AVRO-4297: [ruby] Bound allocation when decoding length-prefixed values and collections Jul 12, 2026
@iemejia iemejia requested a review from Copilot July 12, 2026 15:27
read_long rejects overlong varints, but skip_long (used when projecting away a
long field) looped over continuation bytes with no cap, so a malicious payload
could force scanning unbounded input (CPU DoS) while skipping. Apply the same
10-byte limit and raise AvroError past it.

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.

read_long capped varints at 10 bytes, but a 10-byte encoding can still set bits
beyond bit 63. The 10th byte contributes only bit 63, so reject it when any
higher payload bit (b & 0x7E) is set.

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.

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/ruby/lib/avro/io.rb
Comment on lines 182 to 191
def skip_long
b = byte!
count = 1
while (b & 0x80) != 0
# A 64-bit varint is at most 10 bytes; reject an overlong continuation
# chain so a skipped long can't force scanning unbounded input.
raise AvroError, "Varint is too long" if count >= 10
b = byte!
count += 1
end

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 — skip_long now rejects a 10th byte with payload bits above bit 63 (count == 10 && (b & 0x7E) != 0), consistent with read_long.

Comment thread lang/ruby/lib/avro/io.rb
Comment on lines 388 to +392
if block_count < 0
block_count = -block_count
_block_size = decoder.read_long
end
total = ensure_collection_available(decoder, total, block_count, min_bytes)

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 — read_array now raises on a negative per-block byte-size.

Comment thread lang/ruby/lib/avro/io.rb
Comment on lines 411 to +415
if block_count < 0
block_count = -block_count
_block_size = decoder.read_long
end
total = ensure_collection_available(decoder, total, block_count, min_bytes)

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 — read_map now raises on a negative per-block byte-size.

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.

…block sizes

Address review feedback:
- skip_long now also rejects a 10th varint byte with payload bits above bit 63,
  matching read_long, so a skipped long can't accept an out-of-64-bit-range
  encoding read_long would reject.
- read_array and read_map now reject a negative per-block byte-size in the
  negative-count form (as skip_blocks already does), instead of silently
  accepting malformed input.

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.

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.

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.

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.

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.

2 participants