Copilot commented on code in PR #3863:
URL: https://github.com/apache/avro/pull/3863#discussion_r3566498000
##########
lang/php/lib/Datum/AvroIOBinaryDecoder.php:
##########
@@ -235,28 +273,39 @@ public function skipRecord(AvroRecordSchema
$writersSchema, AvroIOBinaryDecoder
public function skipArray(AvroArraySchema $writersSchema,
AvroIOBinaryDecoder $decoder): void
{
+ $minBytes =
AvroIODatumReader::collectionElementMinBytes($writersSchema->items());
+ $skipped = 0;
$blockCount = $decoder->readLong();
while (0 !== $blockCount) {
if ($blockCount < 0) {
$decoder->skip($this->readLong());
- }
- for ($i = 0; $i < $blockCount; $i++) {
- AvroIODatumReader::skipData($writersSchema->items(), $decoder);
+ } else {
+ AvroIODatumReader::checkSkipCollectionCount($skipped,
$blockCount, $minBytes);
+ $skipped += $blockCount;
+ for ($i = 0; $i < $blockCount; $i++) {
+ AvroIODatumReader::skipData($writersSchema->items(),
$decoder);
+ }
}
$blockCount = $decoder->readLong();
}
}
public function skipMap(AvroMapSchema $writersSchema, AvroIOBinaryDecoder
$decoder): void
{
+ // Map entries always carry a >= 1 byte key, so the minimum is
positive.
+ $minBytes = 1 +
AvroIODatumReader::collectionElementMinBytes($writersSchema->values());
+ $skipped = 0;
$blockCount = $decoder->readLong();
while (0 !== $blockCount) {
Review Comment:
skipMap() uses a strict comparison in the loop condition. On 32-bit builds
with GMP enabled, readLong() can return numeric strings (e.g. '0'), so `0 !==
$blockCount` may never terminate the loop.
##########
lang/php/lib/Datum/AvroIODatumReader.php:
##########
@@ -538,4 +594,118 @@ private function readDecimal(string $bytes, int $scale):
string
return (string) ($scale > 0 ? ($int / (10 ** $scale)) : $int);
}
+
+ /**
+ * 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. It returns 0 for any schema that can encode to
+ * zero bytes: the null primitive, but also a record with no fields or
whose
+ * fields all encode to zero bytes. A zero return disables the collection
+ * check for that element type (so, e.g., an array of nulls is not falsely
+ * rejected). Types that cannot be resolved cheaply default to 1.
+ *
+ * @param array<int, bool> $visited
+ */
+ private static function minBytesPerElement(mixed $schema, array $visited =
[]): int
+ {
+ $type = $schema instanceof AvroSchema ? $schema->type() : $schema;
+ // Named/complex field types may nest a schema object; unwrap one
level.
+ if ($type instanceof AvroSchema) {
+ return self::minBytesPerElement($type, $visited);
+ }
+ if (!is_string($type)) {
+ return 1;
+ }
+ switch ($type) {
+ case AvroSchema::NULL_TYPE:
+ return 0;
+ case AvroSchema::FLOAT_TYPE:
+ return 4;
+ case AvroSchema::DOUBLE_TYPE:
+ return 8;
+ case AvroSchema::FIXED_SCHEMA:
+ return $schema instanceof AvroFixedSchema ? $schema->size() :
1;
+ case AvroSchema::RECORD_SCHEMA:
+ case AvroSchema::ERROR_SCHEMA:
+ if (!$schema instanceof AvroRecordSchema) {
+ return 1;
+ }
+ $id = spl_object_id($schema);
+ if (isset($visited[$id])) {
+ return 1; // self-referencing schema: safe lower bound of
1 byte
+ }
+ $visited[$id] = true;
+ $total = 0;
+ foreach ($schema->fields() as $field) {
+ $total += self::minBytesPerElement($field, $visited);
+ }
Review Comment:
minBytesPerElement() iterates AvroRecordSchema fields as AvroField objects,
but then passes the AvroField itself back into minBytesPerElement(). That makes
every field contribute at least 1 byte, so records whose fields all encode to 0
bytes (e.g. a record with only `null` fields) are misclassified as
non-zero-byte and won’t be capped by the zero-byte collection limit.
##########
lang/php/lib/Datum/AvroIOBinaryDecoder.php:
##########
@@ -235,28 +273,39 @@ public function skipRecord(AvroRecordSchema
$writersSchema, AvroIOBinaryDecoder
public function skipArray(AvroArraySchema $writersSchema,
AvroIOBinaryDecoder $decoder): void
{
+ $minBytes =
AvroIODatumReader::collectionElementMinBytes($writersSchema->items());
+ $skipped = 0;
$blockCount = $decoder->readLong();
while (0 !== $blockCount) {
Review Comment:
skipArray() uses a strict comparison in the loop condition. On 32-bit builds
with GMP enabled, readLong() can return numeric strings (e.g. '0'), so `0 !==
$blockCount` will not terminate the loop correctly.
##########
lang/php/lib/Datum/AvroIODatumReader.php:
##########
@@ -320,6 +346,8 @@ public function readMap(
$decoder->readLong();
}
+ // Map keys are strings (>= 1 byte length prefix) plus the value.
+ self::ensureCollectionAvailable($decoder, count($items),
$pair_count, $minBytes);
for ($i = 0; $i < $pair_count; $i++) {
Review Comment:
readMap() uses count($items) as the "existing" element count for limit
enforcement, but $items is keyed by map keys so duplicate keys overwrite and
reduce count(). This can bypass the configured cumulative limit across blocks
(and the structural cap) by repeating the same key.
##########
lang/php/lib/Datum/AvroIODatumReader.php:
##########
@@ -538,4 +594,118 @@ private function readDecimal(string $bytes, int $scale):
string
return (string) ($scale > 0 ? ($int / (10 ** $scale)) : $int);
}
+
+ /**
+ * 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. It returns 0 for any schema that can encode to
+ * zero bytes: the null primitive, but also a record with no fields or
whose
+ * fields all encode to zero bytes. A zero return disables the collection
+ * check for that element type (so, e.g., an array of nulls is not falsely
+ * rejected). Types that cannot be resolved cheaply default to 1.
+ *
+ * @param array<int, bool> $visited
+ */
+ private static function minBytesPerElement(mixed $schema, array $visited =
[]): int
+ {
+ $type = $schema instanceof AvroSchema ? $schema->type() : $schema;
+ // Named/complex field types may nest a schema object; unwrap one
level.
+ if ($type instanceof AvroSchema) {
+ return self::minBytesPerElement($type, $visited);
+ }
+ if (!is_string($type)) {
+ return 1;
+ }
+ switch ($type) {
+ case AvroSchema::NULL_TYPE:
+ return 0;
+ case AvroSchema::FLOAT_TYPE:
+ return 4;
+ case AvroSchema::DOUBLE_TYPE:
+ return 8;
+ case AvroSchema::FIXED_SCHEMA:
+ return $schema instanceof AvroFixedSchema ? $schema->size() :
1;
+ case AvroSchema::RECORD_SCHEMA:
+ case AvroSchema::ERROR_SCHEMA:
+ if (!$schema instanceof AvroRecordSchema) {
+ return 1;
+ }
+ $id = spl_object_id($schema);
+ if (isset($visited[$id])) {
+ return 1; // self-referencing schema: safe lower bound of
1 byte
+ }
+ $visited[$id] = true;
+ $total = 0;
+ foreach ($schema->fields() as $field) {
+ $total += self::minBytesPerElement($field, $visited);
+ }
+
+ return $total;
+ default:
+ // boolean, int, long, bytes, string, enum, union, array, map:
+ // all encode to at least one byte.
+ return 1;
+ }
+ }
+
+ /**
+ * Returns the configured collection limits as [zeroByteLimit,
structuralLimit].
+ * AVRO_MAX_COLLECTION_ITEMS, when a non-negative integer, caps both.
+ *
+ * @return array{int, int}
+ */
Review Comment:
The doc comment says AVRO_MAX_COLLECTION_ITEMS "caps" both limits, but
collectionLimits() actually sets both limits to the provided value (and tests
rely on increasing the limit). Please reword to match the behavior to avoid
misconfiguration.
##########
lang/php/test/DatumIOTest.php:
##########
@@ -261,6 +262,225 @@ 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_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(AvroException::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(AvroException::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(AvroException::class);
Review Comment:
This test case is checking that a negative block count for a zero-byte
element type is bounded by the collection-size limit, but it only asserts
AvroException. It should assert AvroIOCollectionSizeException to ensure the
limit guard is what triggers the rejection.
##########
lang/php/test/DatumIOTest.php:
##########
@@ -261,6 +262,225 @@ 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_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(AvroException::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(AvroException::class);
Review Comment:
This test is intended to verify the cumulative collection-size limit across
blocks, but it only asserts AvroException. Asserting
AvroIOCollectionSizeException specifically will better protect against
regressions where a different exception is thrown first.
##########
lang/php/test/DatumIOTest.php:
##########
@@ -261,6 +262,225 @@ 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_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(AvroException::class);
Review Comment:
This test is validating the collection-size limit path, but it only asserts
the base AvroException type. Tightening it to AvroIOCollectionSizeException
will ensure the dedicated limit exception continues to be thrown (and avoids
accidentally passing 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]