Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
361e47d
AVRO-4297: [ruby] Validate available bytes before allocating for leng…
iemejia Jul 11, 2026
ae87eb5
AVRO-4297: [ruby] Fix rubocop offenses in min_bytes_per_element
iemejia Jul 11, 2026
d4eec59
AVRO-4297: [ruby] Reject negative read lengths; treat request like re…
iemejia Jul 11, 2026
34859b4
AVRO-4297: [ruby] Make bytes_remaining resilient; compare via division
iemejia Jul 11, 2026
ea1ac32
AVRO-4297: [ruby] Clamp negative bytes_remaining; cache collection mi…
iemejia Jul 11, 2026
1186853
AVRO-4297: [ruby] Skip the collection check for decoders without byte…
iemejia Jul 12, 2026
1a3d3da
AVRO-4297: [ruby] Tie large-read tests to MAX_UNCHECKED_READ; lighter…
iemejia Jul 12, 2026
c6acf77
AVRO-4297: [ruby] Cap zero-byte collection element allocation
iemejia Jul 12, 2026
6b01dee
AVRO-4297: [ruby] Test INT64_MIN block count is bounded
iemejia Jul 12, 2026
f60163d
AVRO-4297: [ruby] Correct inaccurate negative-count comment
iemejia Jul 12, 2026
547dd67
AVRO-4297: [ruby] Bound negative-block skip; reject negative block size
iemejia Jul 12, 2026
ebf1f8b
AVRO-4297: [ruby] Cap read_long varint length; validate skip block size
iemejia Jul 12, 2026
e54959d
AVRO-4297: [ruby] Bounds-check union and enum indices
iemejia Jul 12, 2026
43f6233
AVRO-4297: [ruby] Memoize collection_limits per reader instance
iemejia Jul 12, 2026
db48317
AVRO-4297: [ruby] Reject truncated reads; bounds-check skip_union index
iemejia Jul 12, 2026
b1863ed
AVRO-4297: [ruby] Bound skip_long to 10 bytes
iemejia Jul 13, 2026
07166bb
AVRO-4297: [ruby] Reject 10-byte varints outside the 64-bit range
iemejia Jul 13, 2026
eb2ec9f
AVRO-4297: [ruby] skip_long 64-bit range check; reject negative read …
iemejia Jul 13, 2026
8598eb3
AVRO-4297: [ruby] Bound zero-byte collection elements per datum, not …
iemejia Aug 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 71 additions & 1 deletion lang/ruby/lib/avro/io.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ def initialize(writers_schema, readers_schema)
class BinaryDecoder
# Read leaf values

# Reads with a declared length above this many bytes are validated
# against the number of bytes actually remaining (when the reader can
# report its size) before allocating, to guard against an out-of-memory
# attack from a malicious or truncated input. Smaller reads skip the
# check to avoid per-value overhead.
MAX_UNCHECKED_READ = 1024 * 1024

# reader is an object on which we can call read, seek and tell.
attr_reader :reader
def initialize(reader)
Expand Down Expand Up @@ -103,10 +110,33 @@ def read_string
end

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
Comment on lines 125 to +129

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).

# cannot report their size fall back to reading directly.
if len < 0
# A negative length would make IO#read return the rest of the stream,
# which bypasses the size check and can allocate without bound.
raise AvroError, "Cannot read a negative number of bytes: #{len}"
end
if len > MAX_UNCHECKED_READ
remaining = bytes_remaining
if remaining && len > remaining
raise AvroError, "Cannot read #{len} bytes, only #{remaining} remaining"
end
end
@reader.read(len)
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 — read now raises AvroError if the underlying IO returns fewer than len bytes (or nil), so a truncated/partial read is rejected rather than silently yielding corrupt data.


# Number of bytes still available to read, or nil when the reader cannot
# report its size. Used to reject a declared length or collection block
# count that exceeds the data actually available before allocating for it.
def bytes_remaining
return nil unless @reader.respond_to?(:size) && @reader.respond_to?(:tell)
@reader.size - @reader.tell
end
Comment on lines +155 to +168

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.


def skip_null
nil
end
Expand Down Expand Up @@ -318,6 +348,7 @@ def read_array(writers_schema, readers_schema, decoder)
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.

block_count.times do
read_items << read_data(writers_schema.items,
readers_schema.items,
Expand All @@ -337,6 +368,8 @@ def read_map(writers_schema, readers_schema, decoder)
block_count = -block_count
_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.

block_count.times do
key = decoder.read_string
read_items[key] = read_data(writers_schema.values,
Expand Down Expand Up @@ -496,6 +529,43 @@ def skip_record(writers_schema, decoder)
end

private
# Minimum number of bytes a single value of the given schema can occupy on
# the wire. Used to reject an array/map block count that could not be
# backed by the bytes remaining. A type that can encode to zero bytes
# (null) returns 0, which disables the check for it (so an array of nulls
# is not falsely rejected).
def min_bytes_per_element(schema, visited = nil)
visited ||= {}.compare_by_identity
case schema.type_sym
when :null then 0
when :float then 4
when :double then 8
when :fixed then schema.size
when :record, :error, :request
return 0 if visited[schema]
visited[schema] = true
total = schema.fields.sum { |field| min_bytes_per_element(field.type, visited) }
visited.delete(schema)
total
else
# boolean, int, long, bytes, string, enum, union, array, map: >= 1 byte
# (a union encodes at least a 1-byte branch index).
1
end
end

# Reject a collection (array or map) block whose declared element count
# could not be backed by the bytes actually remaining, before iterating.
# Skipped when the per-element minimum is zero or when the reader cannot
# report how many bytes remain.
def ensure_collection_available(decoder, count, min_bytes_per_element)
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.

end

def skip_blocks(decoder, &blk)
block_count = decoder.read_long
while block_count != 0
Expand Down
64 changes: 64 additions & 0 deletions lang/ruby/test/test_io.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,70 @@ def test_bytes
check_default('"bytes"', '"foo"', "foo")
end

# A bytes/string value declares a length prefix; a malicious or truncated
# input can declare far more bytes than actually exist. On a reader that can
# 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.

reader = StringIO.new(writer.string)
decoder = Avro::IO::BinaryDecoder.new(reader)
assert_raise(Avro::AvroError) { decoder.read_bytes }
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.

writer = StringIO.new
Avro::IO::BinaryEncoder.new(writer).write_bytes(payload)
reader = StringIO.new(writer.string)
decoder = Avro::IO::BinaryDecoder.new(reader)
assert_equal(payload, decoder.read_bytes)
end

# An array/map block declares an element count; a malicious or truncated input
# can declare far more elements than the remaining bytes could hold. The count
# is validated against the bytes remaining before iterating, using the minimum
# on-wire size of the element schema (so 0-byte elements like null are not
# falsely rejected).
def decode(schema_json, encoded)
schema = Avro::Schema.parse(schema_json)
reader = Avro::IO::DatumReader.new(schema)
reader.read(Avro::IO::BinaryDecoder.new(StringIO.new(encoded)))
end

def test_read_array_rejects_count_beyond_stream
writer = StringIO.new
Avro::IO::BinaryEncoder.new(writer).write_long(1_000_000)
assert_raise(Avro::AvroError) do
decode('{"type":"array","items":"long"}', writer.string)
end
end

def test_read_map_rejects_count_beyond_stream
writer = StringIO.new
Avro::IO::BinaryEncoder.new(writer).write_long(1_000_000)
assert_raise(Avro::AvroError) do
decode('{"type":"map","values":"long"}', writer.string)
end
end

def test_read_array_of_null_not_falsely_rejected
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
Comment on lines +107 to +116

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.


def test_read_array_within_stream_still_reads
schema = Avro::Schema.parse('{"type":"array","items":"long"}')
writer = StringIO.new
Avro::IO::DatumWriter.new(schema).write([1, 2, 3], Avro::IO::BinaryEncoder.new(writer))
assert_equal([1, 2, 3], decode('{"type":"array","items":"long"}', writer.string))
end

def test_int
check('"int"')
check_default('"int"', "5", 5)
Expand Down
Loading