Copilot commented on code in PR #3863:
URL: https://github.com/apache/avro/pull/3863#discussion_r3566900917
##########
lang/php/test/DatumIOTest.php:
##########
@@ -261,6 +262,277 @@ public function
test_invalid_bytes_logical_type_out_of_range(): void
$writer->write("10000", $encoder);
}
+ // 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.
+ public function test_read_bytes_rejects_length_beyond_stream(): void
+ {
+ $io = new AvroStringIO();
+ (new AvroIOBinaryEncoder($io))->writeLong(100 * 1024 * 1024);
+ $io->seek(0);
+ $decoder = new AvroIOBinaryDecoder($io);
+
+ $this->expectException(AvroException::class);
+ $decoder->readBytes();
+ }
+
+ public function test_read_bytes_within_stream_still_reads(): void
+ {
+ $payload = str_repeat('x', 2 * 1024 * 1024);
+ $io = new AvroStringIO();
+ (new AvroIOBinaryEncoder($io))->writeBytes($payload);
+ $io->seek(0);
+ $decoder = new AvroIOBinaryDecoder($io);
+
+ $this->assertEquals($payload, $decoder->readBytes());
+ }
+
+ public function test_read_array_rejects_count_beyond_stream(): void
+ {
+ $io = new AvroStringIO();
+ (new AvroIOBinaryEncoder($io))->writeLong(1000000); // 1,000,000
longs, no data
+ $this->expectException(AvroException::class);
+ $this->decodeWith('{"type":"array","items":"long"}', $io);
+ }
+
+ public function test_read_map_rejects_count_beyond_stream(): void
+ {
+ $io = new AvroStringIO();
+ (new AvroIOBinaryEncoder($io))->writeLong(1000000);
+ $this->expectException(AvroException::class);
+ $this->decodeWith('{"type":"map","values":"long"}', $io);
+ }
+
+ public function test_read_array_of_null_not_falsely_rejected(): void
+ {
+ $count = 100000;
+ $io = new AvroStringIO();
+ $encoder = new AvroIOBinaryEncoder($io);
+ $encoder->writeLong($count); // one block of `count` nulls (zero bytes
each)
+ $encoder->writeLong(0); // end-of-array marker
+ $result = $this->decodeWith('{"type":"array","items":"null"}', $io);
+ $this->assertCount($count, $result);
+ }
+
+ public function test_read_array_within_stream_still_reads(): void
+ {
+ $schema = AvroSchema::parse('{"type":"array","items":"long"}');
+ $io = new AvroStringIO();
+ (new AvroIODatumWriter($schema))->write([1, 2, 3], new
AvroIOBinaryEncoder($io));
+ $this->assertEquals([1, 2, 3],
$this->decodeWith('{"type":"array","items":"long"}', $io));
+ }
+
+ public function test_array_of_null_exceeds_default_limit(): void
+ {
+ // The reported exploit: a ~6 byte payload declaring 200,000,000 nulls.
+ $this->expectException(AvroIOCollectionSizeException::class);
+ $this->decodeWith('{"type":"array","items":"null"}',
self::zeroByteBlock(200000000));
+ }
+
+ public function test_array_of_null_int64_min_block_count(): void
+ {
+ // PHP_INT_MIN as a block count cannot be negated (-PHP_INT_MIN
promotes
+ // to a float), so it must be rejected explicitly. It zig-zag encodes
as
+ // the 10-byte varint below, followed by a block byte-size (0).
+ $io = new AvroStringIO(
+ "\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\x01\x00"
+ );
+ $this->expectException(AvroException::class);
+ $this->decodeWith('{"type":"array","items":"null"}', $io);
+ }
+
+ public function test_array_of_null_within_configured_limit_still_reads():
void
+ {
+ putenv('AVRO_MAX_COLLECTION_ITEMS=1000');
+
+ try {
+ $result = $this->decodeWith('{"type":"array","items":"null"}',
self::zeroByteBlock(1000));
+ $this->assertCount(1000, $result);
+ } finally {
+ putenv('AVRO_MAX_COLLECTION_ITEMS');
+ }
+ }
+
+ public function test_array_of_null_exceeds_configured_limit(): void
+ {
+ putenv('AVRO_MAX_COLLECTION_ITEMS=1000');
+
+ try {
+ $this->expectException(AvroIOCollectionSizeException::class);
+ $this->decodeWith('{"type":"array","items":"null"}',
self::zeroByteBlock(1001));
+ } finally {
+ putenv('AVRO_MAX_COLLECTION_ITEMS');
+ }
+ }
+
+ public function test_array_of_null_cumulative_across_blocks(): void
+ {
+ $io = new AvroStringIO();
+ $encoder = new AvroIOBinaryEncoder($io);
+ $encoder->writeLong(600);
+ $encoder->writeLong(600);
+ $encoder->writeLong(0);
+ putenv('AVRO_MAX_COLLECTION_ITEMS=1000');
+
+ try {
+ $this->expectException(AvroIOCollectionSizeException::class);
+ $this->decodeWith('{"type":"array","items":"null"}', $io);
+ } finally {
+ putenv('AVRO_MAX_COLLECTION_ITEMS');
+ }
+ }
+
+ public function test_array_of_null_negative_block_count(): void
+ {
+ putenv('AVRO_MAX_COLLECTION_ITEMS=1000');
+
+ try {
+ $this->expectException(AvroIOCollectionSizeException::class);
+ $this->decodeWith('{"type":"array","items":"null"}',
self::zeroByteBlock(200000, true));
+ } finally {
+ putenv('AVRO_MAX_COLLECTION_ITEMS');
+ }
+ }
+
+ public function
test_map_cumulative_limit_not_bypassed_by_duplicate_keys(): void
+ {
+ // Two blocks of 600 entries all reusing the same key: count($items)
stays
+ // 1, but the cumulative pairs read (1200) must still exceed the 1000
cap.
+ $io = new AvroStringIO();
+ $encoder = new AvroIOBinaryEncoder($io);
+ foreach ([600, 600] as $blockCount) {
+ $encoder->writeLong($blockCount);
+ for ($i = 0; $i < $blockCount; $i++) {
+ $encoder->writeString('k'); // 1-byte key; null value is 0
bytes
+ }
+ }
+ $encoder->writeLong(0);
+ putenv('AVRO_MAX_COLLECTION_ITEMS=1000');
+
+ try {
+ $this->expectException(AvroIOCollectionSizeException::class);
+ $this->decodeWith('{"type":"map","values":"null"}', $io);
+ } finally {
+ putenv('AVRO_MAX_COLLECTION_ITEMS');
+ }
+ }
+
+ public function test_array_of_zero_length_fixed_exceeds_limit(): void
+ {
+ putenv('AVRO_MAX_COLLECTION_ITEMS=1000');
+
+ try {
+ $this->expectException(AvroException::class);
+
$this->decodeWith('{"type":"array","items":{"type":"fixed","name":"empty","size":0}}',
self::zeroByteBlock(2000));
Review Comment:
This test is specifically exercising the zero-byte collection item limit
(fixed size 0 encodes to 0 bytes), but it only asserts the base AvroException.
Asserting AvroIOCollectionSizeException makes sure the dedicated
collection-size guard is what rejects the input (and prevents the test from
passing due to unrelated decoding failures).
##########
lang/php/test/DatumIOTest.php:
##########
@@ -261,6 +262,277 @@ public function
test_invalid_bytes_logical_type_out_of_range(): void
$writer->write("10000", $encoder);
}
+ // 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.
+ public function test_read_bytes_rejects_length_beyond_stream(): void
+ {
+ $io = new AvroStringIO();
+ (new AvroIOBinaryEncoder($io))->writeLong(100 * 1024 * 1024);
+ $io->seek(0);
+ $decoder = new AvroIOBinaryDecoder($io);
+
+ $this->expectException(AvroException::class);
+ $decoder->readBytes();
+ }
+
+ public function test_read_bytes_within_stream_still_reads(): void
+ {
+ $payload = str_repeat('x', 2 * 1024 * 1024);
+ $io = new AvroStringIO();
+ (new AvroIOBinaryEncoder($io))->writeBytes($payload);
+ $io->seek(0);
+ $decoder = new AvroIOBinaryDecoder($io);
+
+ $this->assertEquals($payload, $decoder->readBytes());
+ }
+
+ public function test_read_array_rejects_count_beyond_stream(): void
+ {
+ $io = new AvroStringIO();
+ (new AvroIOBinaryEncoder($io))->writeLong(1000000); // 1,000,000
longs, no data
+ $this->expectException(AvroException::class);
+ $this->decodeWith('{"type":"array","items":"long"}', $io);
+ }
+
+ public function test_read_map_rejects_count_beyond_stream(): void
+ {
+ $io = new AvroStringIO();
+ (new AvroIOBinaryEncoder($io))->writeLong(1000000);
+ $this->expectException(AvroException::class);
+ $this->decodeWith('{"type":"map","values":"long"}', $io);
+ }
+
+ public function test_read_array_of_null_not_falsely_rejected(): void
+ {
+ $count = 100000;
+ $io = new AvroStringIO();
+ $encoder = new AvroIOBinaryEncoder($io);
+ $encoder->writeLong($count); // one block of `count` nulls (zero bytes
each)
+ $encoder->writeLong(0); // end-of-array marker
+ $result = $this->decodeWith('{"type":"array","items":"null"}', $io);
+ $this->assertCount($count, $result);
+ }
+
+ public function test_read_array_within_stream_still_reads(): void
+ {
+ $schema = AvroSchema::parse('{"type":"array","items":"long"}');
+ $io = new AvroStringIO();
+ (new AvroIODatumWriter($schema))->write([1, 2, 3], new
AvroIOBinaryEncoder($io));
+ $this->assertEquals([1, 2, 3],
$this->decodeWith('{"type":"array","items":"long"}', $io));
+ }
+
+ public function test_array_of_null_exceeds_default_limit(): void
+ {
+ // The reported exploit: a ~6 byte payload declaring 200,000,000 nulls.
+ $this->expectException(AvroIOCollectionSizeException::class);
+ $this->decodeWith('{"type":"array","items":"null"}',
self::zeroByteBlock(200000000));
+ }
+
+ public function test_array_of_null_int64_min_block_count(): void
+ {
+ // PHP_INT_MIN as a block count cannot be negated (-PHP_INT_MIN
promotes
+ // to a float), so it must be rejected explicitly. It zig-zag encodes
as
+ // the 10-byte varint below, followed by a block byte-size (0).
+ $io = new AvroStringIO(
+ "\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\x01\x00"
+ );
+ $this->expectException(AvroException::class);
+ $this->decodeWith('{"type":"array","items":"null"}', $io);
+ }
+
+ public function test_array_of_null_within_configured_limit_still_reads():
void
+ {
+ putenv('AVRO_MAX_COLLECTION_ITEMS=1000');
+
+ try {
+ $result = $this->decodeWith('{"type":"array","items":"null"}',
self::zeroByteBlock(1000));
+ $this->assertCount(1000, $result);
+ } finally {
+ putenv('AVRO_MAX_COLLECTION_ITEMS');
+ }
+ }
+
+ public function test_array_of_null_exceeds_configured_limit(): void
+ {
+ putenv('AVRO_MAX_COLLECTION_ITEMS=1000');
+
+ try {
+ $this->expectException(AvroIOCollectionSizeException::class);
+ $this->decodeWith('{"type":"array","items":"null"}',
self::zeroByteBlock(1001));
+ } finally {
+ putenv('AVRO_MAX_COLLECTION_ITEMS');
+ }
+ }
+
+ public function test_array_of_null_cumulative_across_blocks(): void
+ {
+ $io = new AvroStringIO();
+ $encoder = new AvroIOBinaryEncoder($io);
+ $encoder->writeLong(600);
+ $encoder->writeLong(600);
+ $encoder->writeLong(0);
+ putenv('AVRO_MAX_COLLECTION_ITEMS=1000');
+
+ try {
+ $this->expectException(AvroIOCollectionSizeException::class);
+ $this->decodeWith('{"type":"array","items":"null"}', $io);
+ } finally {
+ putenv('AVRO_MAX_COLLECTION_ITEMS');
+ }
+ }
+
+ public function test_array_of_null_negative_block_count(): void
+ {
+ putenv('AVRO_MAX_COLLECTION_ITEMS=1000');
+
+ try {
+ $this->expectException(AvroIOCollectionSizeException::class);
+ $this->decodeWith('{"type":"array","items":"null"}',
self::zeroByteBlock(200000, true));
+ } finally {
+ putenv('AVRO_MAX_COLLECTION_ITEMS');
+ }
+ }
+
+ public function
test_map_cumulative_limit_not_bypassed_by_duplicate_keys(): void
+ {
+ // Two blocks of 600 entries all reusing the same key: count($items)
stays
+ // 1, but the cumulative pairs read (1200) must still exceed the 1000
cap.
+ $io = new AvroStringIO();
+ $encoder = new AvroIOBinaryEncoder($io);
+ foreach ([600, 600] as $blockCount) {
+ $encoder->writeLong($blockCount);
+ for ($i = 0; $i < $blockCount; $i++) {
+ $encoder->writeString('k'); // 1-byte key; null value is 0
bytes
+ }
+ }
+ $encoder->writeLong(0);
+ putenv('AVRO_MAX_COLLECTION_ITEMS=1000');
+
+ try {
+ $this->expectException(AvroIOCollectionSizeException::class);
+ $this->decodeWith('{"type":"map","values":"null"}', $io);
+ } finally {
+ putenv('AVRO_MAX_COLLECTION_ITEMS');
+ }
+ }
+
+ public function test_array_of_zero_length_fixed_exceeds_limit(): void
+ {
+ putenv('AVRO_MAX_COLLECTION_ITEMS=1000');
+
+ try {
+ $this->expectException(AvroException::class);
+
$this->decodeWith('{"type":"array","items":{"type":"fixed","name":"empty","size":0}}',
self::zeroByteBlock(2000));
+ } finally {
+ putenv('AVRO_MAX_COLLECTION_ITEMS');
+ }
+ }
+
+ public function test_array_of_all_null_record_exceeds_limit(): void
+ {
+ putenv('AVRO_MAX_COLLECTION_ITEMS=1000');
+
+ try {
+ $this->expectException(AvroException::class);
+ $this->decodeWith(
+
'{"type":"array","items":{"type":"record","name":"R","fields":[{"name":"n","type":"null"}]}}',
+ self::zeroByteBlock(2000)
+ );
+ } finally {
+ putenv('AVRO_MAX_COLLECTION_ITEMS');
+ }
+ }
+
+ public function test_map_of_null_rejected_by_available_bytes(): void
+ {
+ // Map entries always carry a >= 1 byte key, so a huge map<null> is
bounded
+ // by the bytes-remaining check.
+ $this->expectException(AvroException::class);
+ $this->decodeWith('{"type":"map","values":"null"}',
self::zeroByteBlock(200000000));
+ }
+
+ public function test_invalid_env_override_falls_back_to_default(): void
+ {
+ // An invalid limit is ignored, so the default (10,000,000) still
rejects
+ // the 200,000,000 exploit.
+ putenv('AVRO_MAX_COLLECTION_ITEMS=not-a-number');
+
+ try {
+ $this->expectException(AvroException::class);
+ $this->decodeWith('{"type":"array","items":"null"}',
self::zeroByteBlock(200000000));
Review Comment:
This test is intended to confirm that an invalid AVRO_MAX_COLLECTION_ITEMS
value is ignored and the default zero-byte collection limit still rejects the
exploit payload. Since the rejection should come from the collection-size
limit, asserting AvroIOCollectionSizeException (rather than AvroException) will
better protect against regressions.
##########
lang/php/test/DatumIOTest.php:
##########
@@ -261,6 +262,277 @@ public function
test_invalid_bytes_logical_type_out_of_range(): void
$writer->write("10000", $encoder);
}
+ // 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.
+ public function test_read_bytes_rejects_length_beyond_stream(): void
+ {
+ $io = new AvroStringIO();
+ (new AvroIOBinaryEncoder($io))->writeLong(100 * 1024 * 1024);
+ $io->seek(0);
+ $decoder = new AvroIOBinaryDecoder($io);
+
+ $this->expectException(AvroException::class);
+ $decoder->readBytes();
+ }
+
+ public function test_read_bytes_within_stream_still_reads(): void
+ {
+ $payload = str_repeat('x', 2 * 1024 * 1024);
+ $io = new AvroStringIO();
+ (new AvroIOBinaryEncoder($io))->writeBytes($payload);
+ $io->seek(0);
+ $decoder = new AvroIOBinaryDecoder($io);
+
+ $this->assertEquals($payload, $decoder->readBytes());
+ }
+
+ public function test_read_array_rejects_count_beyond_stream(): void
+ {
+ $io = new AvroStringIO();
+ (new AvroIOBinaryEncoder($io))->writeLong(1000000); // 1,000,000
longs, no data
+ $this->expectException(AvroException::class);
+ $this->decodeWith('{"type":"array","items":"long"}', $io);
+ }
+
+ public function test_read_map_rejects_count_beyond_stream(): void
+ {
+ $io = new AvroStringIO();
+ (new AvroIOBinaryEncoder($io))->writeLong(1000000);
+ $this->expectException(AvroException::class);
+ $this->decodeWith('{"type":"map","values":"long"}', $io);
+ }
+
+ public function test_read_array_of_null_not_falsely_rejected(): void
+ {
+ $count = 100000;
+ $io = new AvroStringIO();
+ $encoder = new AvroIOBinaryEncoder($io);
+ $encoder->writeLong($count); // one block of `count` nulls (zero bytes
each)
+ $encoder->writeLong(0); // end-of-array marker
+ $result = $this->decodeWith('{"type":"array","items":"null"}', $io);
+ $this->assertCount($count, $result);
+ }
+
+ public function test_read_array_within_stream_still_reads(): void
+ {
+ $schema = AvroSchema::parse('{"type":"array","items":"long"}');
+ $io = new AvroStringIO();
+ (new AvroIODatumWriter($schema))->write([1, 2, 3], new
AvroIOBinaryEncoder($io));
+ $this->assertEquals([1, 2, 3],
$this->decodeWith('{"type":"array","items":"long"}', $io));
+ }
+
+ public function test_array_of_null_exceeds_default_limit(): void
+ {
+ // The reported exploit: a ~6 byte payload declaring 200,000,000 nulls.
+ $this->expectException(AvroIOCollectionSizeException::class);
+ $this->decodeWith('{"type":"array","items":"null"}',
self::zeroByteBlock(200000000));
+ }
+
+ public function test_array_of_null_int64_min_block_count(): void
+ {
+ // PHP_INT_MIN as a block count cannot be negated (-PHP_INT_MIN
promotes
+ // to a float), so it must be rejected explicitly. It zig-zag encodes
as
+ // the 10-byte varint below, followed by a block byte-size (0).
+ $io = new AvroStringIO(
+ "\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\x01\x00"
+ );
+ $this->expectException(AvroException::class);
+ $this->decodeWith('{"type":"array","items":"null"}', $io);
+ }
+
+ public function test_array_of_null_within_configured_limit_still_reads():
void
+ {
+ putenv('AVRO_MAX_COLLECTION_ITEMS=1000');
+
+ try {
+ $result = $this->decodeWith('{"type":"array","items":"null"}',
self::zeroByteBlock(1000));
+ $this->assertCount(1000, $result);
+ } finally {
+ putenv('AVRO_MAX_COLLECTION_ITEMS');
+ }
+ }
+
+ public function test_array_of_null_exceeds_configured_limit(): void
+ {
+ putenv('AVRO_MAX_COLLECTION_ITEMS=1000');
+
+ try {
+ $this->expectException(AvroIOCollectionSizeException::class);
+ $this->decodeWith('{"type":"array","items":"null"}',
self::zeroByteBlock(1001));
+ } finally {
+ putenv('AVRO_MAX_COLLECTION_ITEMS');
+ }
+ }
+
+ public function test_array_of_null_cumulative_across_blocks(): void
+ {
+ $io = new AvroStringIO();
+ $encoder = new AvroIOBinaryEncoder($io);
+ $encoder->writeLong(600);
+ $encoder->writeLong(600);
+ $encoder->writeLong(0);
+ putenv('AVRO_MAX_COLLECTION_ITEMS=1000');
+
+ try {
+ $this->expectException(AvroIOCollectionSizeException::class);
+ $this->decodeWith('{"type":"array","items":"null"}', $io);
+ } finally {
+ putenv('AVRO_MAX_COLLECTION_ITEMS');
+ }
+ }
+
+ public function test_array_of_null_negative_block_count(): void
+ {
+ putenv('AVRO_MAX_COLLECTION_ITEMS=1000');
+
+ try {
+ $this->expectException(AvroIOCollectionSizeException::class);
+ $this->decodeWith('{"type":"array","items":"null"}',
self::zeroByteBlock(200000, true));
+ } finally {
+ putenv('AVRO_MAX_COLLECTION_ITEMS');
+ }
+ }
+
+ public function
test_map_cumulative_limit_not_bypassed_by_duplicate_keys(): void
+ {
+ // Two blocks of 600 entries all reusing the same key: count($items)
stays
+ // 1, but the cumulative pairs read (1200) must still exceed the 1000
cap.
+ $io = new AvroStringIO();
+ $encoder = new AvroIOBinaryEncoder($io);
+ foreach ([600, 600] as $blockCount) {
+ $encoder->writeLong($blockCount);
+ for ($i = 0; $i < $blockCount; $i++) {
+ $encoder->writeString('k'); // 1-byte key; null value is 0
bytes
+ }
+ }
+ $encoder->writeLong(0);
+ putenv('AVRO_MAX_COLLECTION_ITEMS=1000');
+
+ try {
+ $this->expectException(AvroIOCollectionSizeException::class);
+ $this->decodeWith('{"type":"map","values":"null"}', $io);
+ } finally {
+ putenv('AVRO_MAX_COLLECTION_ITEMS');
+ }
+ }
+
+ public function test_array_of_zero_length_fixed_exceeds_limit(): void
+ {
+ putenv('AVRO_MAX_COLLECTION_ITEMS=1000');
+
+ try {
+ $this->expectException(AvroException::class);
+
$this->decodeWith('{"type":"array","items":{"type":"fixed","name":"empty","size":0}}',
self::zeroByteBlock(2000));
+ } finally {
+ putenv('AVRO_MAX_COLLECTION_ITEMS');
+ }
+ }
+
+ public function test_array_of_all_null_record_exceeds_limit(): void
+ {
+ putenv('AVRO_MAX_COLLECTION_ITEMS=1000');
+
+ try {
+ $this->expectException(AvroException::class);
+ $this->decodeWith(
+
'{"type":"array","items":{"type":"record","name":"R","fields":[{"name":"n","type":"null"}]}}',
+ self::zeroByteBlock(2000)
+ );
Review Comment:
This test is validating the zero-byte collection item limit (a record whose
fields all encode to 0 bytes), but it only asserts AvroException. Asserting
AvroIOCollectionSizeException ensures the intended collection-size guard
triggers and the test won’t accidentally pass due to other AvroExceptions.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]