Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
92e0e5d
AVRO-4298: [php] Validate available bytes before allocating for lengt…
iemejia Jul 11, 2026
5e997eb
AVRO-4298: [php] Reject negative lengths; avoid overflow; GMP-safe bl…
iemejia Jul 11, 2026
f3a4764
AVRO-4298: [php] Clamp bytesRemaining to 0; conservative cycle minimum
iemejia Jul 11, 2026
bd6bb27
AVRO-4298: [php] Clarify minBytesPerElement doc comment
iemejia Jul 11, 2026
3d7f736
AVRO-4298: [php] Cap zero-byte collection element allocation
iemejia Jul 12, 2026
695a4b2
AVRO-4298: [php] Reject PHP_INT_MIN array/map block count
iemejia Jul 12, 2026
23f15e0
AVRO-4298: [php] Fix cumulative map cap, GMP skip loops, minor cleanups
iemejia Jul 12, 2026
063699d
AVRO-4298: [php] Bound negative-block skips; non-strict PHP_INT_MIN c…
iemejia Jul 12, 2026
542afad
AVRO-4298: [php] Assert AvroIOCollectionSizeException in zero-byte tests
iemejia Jul 12, 2026
2db3fd1
AVRO-4298: [php] Reject negative block size on negative array/map blocks
iemejia Jul 12, 2026
0d02b5b
AVRO-4298: [php] Restore original AVRO_MAX_COLLECTION_ITEMS in tests
iemejia Jul 12, 2026
8d2b1cb
AVRO-4298: [php] Reject negative union branch index
iemejia Jul 12, 2026
f6ffd01
AVRO-4298: [php] Reject overlong varints in readLong
iemejia Jul 12, 2026
f448112
AVRO-4298: [php] Validate byte-sized skip blocks against bytes remaining
iemejia Jul 13, 2026
33c9885
AVRO-4298: [php] Clamp fixed-schema minimum size to non-negative
iemejia Jul 13, 2026
c5a8634
AVRO-4298: [php] Verify sized skip block can hold the declared elemen…
iemejia Jul 13, 2026
c5e93a3
AVRO-4298: [php] Bound zero-byte collection elements per datum, not p…
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
38 changes: 38 additions & 0 deletions lang/php/lib/Datum/AvroIOBinaryDecoder.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@
*/
class AvroIOBinaryDecoder
{
/**
* Reads with a declared length above this many bytes are validated against
* the number of bytes actually remaining 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.
*/
private const MAX_UNCHECKED_READ = 1048576; // 1 MiB

/**
* @param AvroIO $io object from which to read.
*/
Expand Down Expand Up @@ -65,9 +73,39 @@ public function readBoolean(): bool
*/
public function read(int $len): string
{
if ($len < 0) {
// AvroStringIO::read() accepts a negative length and moves the
// pointer backwards; reject it before delegating.
throw new AvroException("Cannot read a negative number of bytes: {$len}");
}
if ($len > self::MAX_UNCHECKED_READ) {
$remaining = $this->bytesRemaining();
if ($len > $remaining) {
throw new AvroException("Cannot read {$len} bytes, only {$remaining} remaining.");
}
}

Comment on lines 86 to +99

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 5e997eb: read(int) now rejects a negative length before delegating to AvroIO::read().

return $this->io->read($len);
}

/**
* Number of bytes still available to read, determined by seeking to the end
* and restoring the position (which both the string and file IO
* implementations support). Used to reject a declared length or collection
* block count that exceeds the data actually available before allocating.
*/
public function bytesRemaining(): int
{
$current = $this->io->tell();
$this->io->seek(0, AvroIO::SEEK_END);
$end = $this->io->tell();
$this->io->seek($current, AvroIO::SEEK_SET);

// Clamp to 0: AvroStringIO::seek() allows seeking past EOF, which would
// otherwise yield a confusing negative "remaining" count.
return max(0, $end - $current);
}
Comment on lines +111 to +119

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 f3a4764: bytesRemaining() clamps to 0 via max(0, ...), so a position past EOF no longer yields a negative remaining count.


public function readInt(): int
{
return (int) $this->readLong();
Expand Down
82 changes: 81 additions & 1 deletion lang/php/lib/Datum/AvroIODatumReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -285,11 +285,12 @@ public function readArray(
): array {
$items = [];
$blockCount = $decoder->readLong();
while (0 !== $blockCount) {
while (0 != $blockCount) {
if ($blockCount < 0) {
$blockCount = -$blockCount;
$decoder->readLong(); // Read (and ignore) block size

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 2db3fd1. The array block-size long is now validated and a negative value is rejected with AvroException('Invalid negative array block size'), matching the skip path.

}
self::ensureCollectionAvailable($decoder, $blockCount, self::minBytesPerElement($writersSchema->items()));
for ($i = 0; $i < $blockCount; $i++) {
Comment on lines 329 to 331

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 5e997eb: readArray now uses a non-strict comparison (0 != blockCount), matching readMap, so a GMP numeric-string block count of '0' terminates the loop on 32-bit builds.

$items[] = $this->readData(
$writersSchema->items(),
Expand Down Expand Up @@ -320,6 +321,8 @@ public function readMap(
$decoder->readLong();

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 2db3fd1. The map block-size long is now validated and rejected if negative, consistent with skipMap().

}

// Map keys are strings (>= 1 byte length prefix) plus the value.
self::ensureCollectionAvailable($decoder, $pair_count, 1 + self::minBytesPerElement($writersSchema->values()));
for ($i = 0; $i < $pair_count; $i++) {
Comment on lines +370 to 376

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 23f15e0: readMap now bounds against a separate cumulative counter rather than count($items), so repeating a key can't shrink the count and slip past the cap. Added a regression test.

$key = $decoder->readString();
$items[$key] = $this->readData(
Expand Down Expand Up @@ -538,4 +541,81 @@ 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;

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 — minBytesPerElement now returns max(0, $schema->size()) for fixed, so a malformed negative fixed size can't make the minimum negative and cause the element to be mis-treated as zero-byte.

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
}
Comment on lines +672 to +675

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 f3a4764: minBytesPerElement() returns 1 (not 0) on a self-referencing cycle, keeping the guard conservative for arrays/maps of recursive records.

$visited[$id] = true;
$total = 0;
foreach ($schema->fields() as $field) {
$total += self::minBytesPerElement($field, $visited);
}
Comment on lines +678 to +680

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.

Made explicit in 23f15e0 (now traverses via $field->type()). Note this was already correct at runtime: AvroField extends AvroSchema, so minBytesPerElement(field) used $field->type() internally and a record of null fields returned 0 (verified) — but $field->type() is clearer.


return $total;
default:
// boolean, int, long, bytes, string, enum, union, array, map:
// all encode to at least one byte.
return 1;
}
}

/**
* Rejects 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 (e.g. an array of nulls).
*
* @throws AvroException if the declared count exceeds the bytes remaining
*/
private static function ensureCollectionAvailable(
AvroIOBinaryDecoder $decoder,
int $count,
int $minBytesPerElement
): void {
if ($count <= 0 || $minBytesPerElement <= 0) {
return;
}
$remaining = $decoder->bytesRemaining();
if ($count > intdiv($remaining, $minBytesPerElement)) {
throw new AvroException(
"Collection claims {$count} elements with at least {$minBytesPerElement} "
."bytes each, but only {$remaining} bytes are available."
);
}
}
}
74 changes: 74 additions & 0 deletions lang/php/test/DatumIOTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,66 @@ 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 static function validDurationLogicalTypes(): array
{
return [
Expand Down Expand Up @@ -420,6 +480,20 @@ public function test_field_default_value(
}
}

// 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).
private function decodeWith(string $schemaJson, AvroStringIO $io): mixed
{
$schema = AvroSchema::parse($schemaJson);
$io->seek(0);
$reader = new AvroIODatumReader($schema);

return $reader->read(new AvroIOBinaryDecoder($io));
}

/**
* @param string $datum
* @param string $expected
Expand Down
Loading