From 6014236afc2461001b46ba3cfb67058855a8b8fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isma=C3=ABl=20Mej=C3=ADa?= Date: Sat, 11 Jul 2026 14:53:41 +0200 Subject: [PATCH 1/9] AVRO-4289: [php] Enforce a maximum decompressed block size When reading a data file, each block is decompressed according to the file's codec. A block with a very high compression ratio (or a malformed block) could expand to far more memory than its compressed size. Enforce a configurable maximum decompressed size across the deflate, zstandard, snappy and bzip2 codecs, mirroring the Java SDK's decompression limit (AVRO-4247): deflate caps its output via gzinflate's max length so the allocation itself is bounded, and snappy rejects an over-large declared length up front. The limit defaults to 200 MiB and can be overridden with the AVRO_MAX_DECOMPRESS_LENGTH environment variable; exceeding it throws AvroDataIODecompressionSizeException. Assisted-by: GitHub Copilot:claude-opus-4.8 --- .../AvroDataIODecompressionSizeException.php | 40 +++++++++ lang/php/lib/DataFile/AvroDataIOReader.php | 83 ++++++++++++++++++- lang/php/lib/autoload.php | 1 + lang/php/test/DataFileTest.php | 59 +++++++++++++ 4 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 lang/php/lib/DataFile/AvroDataIODecompressionSizeException.php diff --git a/lang/php/lib/DataFile/AvroDataIODecompressionSizeException.php b/lang/php/lib/DataFile/AvroDataIODecompressionSizeException.php new file mode 100644 index 00000000000..5748f48b389 --- /dev/null +++ b/lang/php/lib/DataFile/AvroDataIODecompressionSizeException.php @@ -0,0 +1,40 @@ + object container metadata @@ -220,17 +231,47 @@ private function readBlockHeader(): string|int return $this->decoder->readLong(); } + /** + * The maximum number of bytes a single block is allowed to decompress to. + */ + private static function maxDecompressLength(): int + { + $value = getenv(self::MAX_DECOMPRESS_LENGTH_ENV); + if (false !== $value && ctype_digit($value) && (int) $value > 0) { + return (int) $value; + } + + return self::DEFAULT_MAX_DECOMPRESS_LENGTH; + } + + /** + * @throws AvroDataIODecompressionSizeException if the length exceeds the limit + */ + private static function checkDecompressLength(int $length, int $maxLength): void + { + if ($length > $maxLength) { + throw new AvroDataIODecompressionSizeException($maxLength); + } + } + /** * @throws AvroException */ private function gzUncompress(string $compressed): string { - $datum = gzinflate($compressed); + $maxLength = self::maxDecompressLength(); + // gzinflate caps its output at the given length: a block that would + // decompress to more than the limit yields false here without + // materializing the full (potentially huge) output. The '@' suppresses + // the "insufficient memory" notice zlib emits when the cap is hit. + $datum = @gzinflate($compressed, $maxLength + 1); if (false === $datum) { - throw new AvroException('gzip uncompression failed.'); + throw new AvroDataIODecompressionSizeException($maxLength); } + self::checkDecompressLength(strlen($datum), $maxLength); + return $datum; } @@ -248,6 +289,8 @@ private function zstdUncompress(string $compressed): string throw new AvroException('zstd uncompression failed.'); } + self::checkDecompressLength(strlen($datum), self::maxDecompressLength()); + return $datum; } @@ -265,6 +308,8 @@ private function bzUncompress(string $compressed): string throw new AvroException('bz2 uncompression failed.'); } + self::checkDecompressLength(strlen($datum), self::maxDecompressLength()); + return $datum; } @@ -276,6 +321,13 @@ private function snappyUncompress(string $compressed): string if (!extension_loaded('snappy')) { throw new AvroException('Please install ext-snappy to use snappy compression.'); } + $maxLength = self::maxDecompressLength(); + // The Snappy block header declares the uncompressed length as a varint; + // reject an over-large block before allocating for it. + $declared = self::snappyDeclaredLength(substr((string) $compressed, 0, -4)); + if (null !== $declared) { + self::checkDecompressLength($declared, $maxLength); + } $crc32 = unpack('N', substr((string) $compressed, -4))[1]; $datum = snappy_uncompress(substr((string) $compressed, 0, -4)); @@ -283,10 +335,37 @@ private function snappyUncompress(string $compressed): string throw new AvroException('snappy uncompression failed.'); } + self::checkDecompressLength(strlen($datum), $maxLength); + if ($crc32 !== crc32($datum)) { throw new AvroException('snappy uncompression failed - crc32 mismatch.'); } return $datum; } + + /** + * Return the uncompressed length declared in a raw Snappy block header, + * which prefixes the data as a little-endian base-128 varint. Returns null + * if the header cannot be parsed. + */ + private static function snappyDeclaredLength(string $data): ?int + { + $result = 0; + $shift = 0; + $length = strlen($data); + for ($i = 0; $i < $length; $i++) { + $byte = ord($data[$i]); + $result |= ($byte & 0x7F) << $shift; + if (0 === ($byte & 0x80)) { + return $result; + } + $shift += 7; + if ($shift > 63) { + break; + } + } + + return null; + } } diff --git a/lang/php/lib/autoload.php b/lang/php/lib/autoload.php index de1a863513b..09b6c56c7c4 100644 --- a/lang/php/lib/autoload.php +++ b/lang/php/lib/autoload.php @@ -27,6 +27,7 @@ include __DIR__.'/AvroUtil.php'; include __DIR__.'/DataFile/AvroDataIO.php'; +include __DIR__.'/DataFile/AvroDataIODecompressionSizeException.php'; include __DIR__.'/DataFile/AvroDataIOException.php'; include __DIR__.'/DataFile/AvroDataIOReader.php'; include __DIR__.'/DataFile/AvroDataIOWriter.php'; diff --git a/lang/php/test/DataFileTest.php b/lang/php/test/DataFileTest.php index 7810c593a78..e90a176aade 100644 --- a/lang/php/test/DataFileTest.php +++ b/lang/php/test/DataFileTest.php @@ -23,6 +23,8 @@ namespace Apache\Avro\Tests; use Apache\Avro\DataFile\AvroDataIO; +use Apache\Avro\DataFile\AvroDataIODecompressionSizeException; +use Apache\Avro\DataFile\AvroDataIOReader; use PHPUnit\Framework\TestCase; class DataFileTest extends TestCase @@ -395,6 +397,63 @@ protected function add_data_file(string $data_file): string return $full; } + /** + * A block with a very high compression ratio can expand to far more memory + * than its compressed size; reading such a block must be rejected once its + * decompressed size would exceed the configured maximum. + */ + public function test_deflate_block_decompression_limit(): void + { + $data_file = $this->add_data_file('data-decompress-limit-deflate.avr'); + $dw = AvroDataIO::openFile($data_file, 'w', '"string"', AvroDataIO::DEFLATE_CODEC); + $dw->append(str_repeat('a', 64 * 1024)); // 64 KiB, compresses tiny + $dw->close(); + + $previous = getenv(AvroDataIOReader::MAX_DECOMPRESS_LENGTH_ENV); + putenv(AvroDataIOReader::MAX_DECOMPRESS_LENGTH_ENV . '=1024'); + try { + $dr = AvroDataIO::openFile($data_file); + $thrown = false; + try { + $dr->data(); + } catch (AvroDataIODecompressionSizeException $e) { + $thrown = true; + } + $dr->close(); + $this->assertTrue($thrown, 'expected a decompression size exception'); + } finally { + if (false === $previous) { + putenv(AvroDataIOReader::MAX_DECOMPRESS_LENGTH_ENV); + } else { + putenv(AvroDataIOReader::MAX_DECOMPRESS_LENGTH_ENV . '=' . $previous); + } + } + } + + public function test_deflate_block_within_decompression_limit(): void + { + $data_file = $this->add_data_file('data-decompress-within-limit.avr'); + $payload = 'hello world'; + $dw = AvroDataIO::openFile($data_file, 'w', '"string"', AvroDataIO::DEFLATE_CODEC); + $dw->append($payload); + $dw->close(); + + $previous = getenv(AvroDataIOReader::MAX_DECOMPRESS_LENGTH_ENV); + putenv(AvroDataIOReader::MAX_DECOMPRESS_LENGTH_ENV . '=1048576'); + try { + $dr = AvroDataIO::openFile($data_file); + $data = $dr->data(); + $dr->close(); + $this->assertSame([$payload], $data); + } finally { + if (false === $previous) { + putenv(AvroDataIOReader::MAX_DECOMPRESS_LENGTH_ENV); + } else { + putenv(AvroDataIOReader::MAX_DECOMPRESS_LENGTH_ENV . '=' . $previous); + } + } + } + protected function remove_data_files(): void { /** @phpstan-ignore booleanAnd.leftAlwaysTrue */ From 2ce33fa8b270f2853d3179c4898d9260c42c7fe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isma=C3=ABl=20Mej=C3=ADa?= Date: Sat, 11 Jul 2026 15:10:08 +0200 Subject: [PATCH 2/9] AVRO-4289: [php] Satisfy php-cs-fixer style checks Reorder the new public test methods before the protected helper and drop spaces around string concatenation to match the lint rules. Assisted-by: GitHub Copilot:claude-opus-4.8 --- lang/php/test/DataFileTest.php | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/lang/php/test/DataFileTest.php b/lang/php/test/DataFileTest.php index e90a176aade..4ee9292cb23 100644 --- a/lang/php/test/DataFileTest.php +++ b/lang/php/test/DataFileTest.php @@ -388,15 +388,6 @@ public function test_differing_schemas_with_complex_objects(): void } } - protected function add_data_file(string $data_file): string - { - $data_file = "$data_file.".self::current_timestamp(); - $full = implode(DIRECTORY_SEPARATOR, [TEST_TEMP_DIR, $data_file]); - $this->dataFiles[] = $full; - - return $full; - } - /** * A block with a very high compression ratio can expand to far more memory * than its compressed size; reading such a block must be rejected once its @@ -410,10 +401,12 @@ public function test_deflate_block_decompression_limit(): void $dw->close(); $previous = getenv(AvroDataIOReader::MAX_DECOMPRESS_LENGTH_ENV); - putenv(AvroDataIOReader::MAX_DECOMPRESS_LENGTH_ENV . '=1024'); + putenv(AvroDataIOReader::MAX_DECOMPRESS_LENGTH_ENV.'=1024'); + try { $dr = AvroDataIO::openFile($data_file); $thrown = false; + try { $dr->data(); } catch (AvroDataIODecompressionSizeException $e) { @@ -425,7 +418,7 @@ public function test_deflate_block_decompression_limit(): void if (false === $previous) { putenv(AvroDataIOReader::MAX_DECOMPRESS_LENGTH_ENV); } else { - putenv(AvroDataIOReader::MAX_DECOMPRESS_LENGTH_ENV . '=' . $previous); + putenv(AvroDataIOReader::MAX_DECOMPRESS_LENGTH_ENV.'='.$previous); } } } @@ -439,7 +432,8 @@ public function test_deflate_block_within_decompression_limit(): void $dw->close(); $previous = getenv(AvroDataIOReader::MAX_DECOMPRESS_LENGTH_ENV); - putenv(AvroDataIOReader::MAX_DECOMPRESS_LENGTH_ENV . '=1048576'); + putenv(AvroDataIOReader::MAX_DECOMPRESS_LENGTH_ENV.'=1048576'); + try { $dr = AvroDataIO::openFile($data_file); $data = $dr->data(); @@ -449,11 +443,20 @@ public function test_deflate_block_within_decompression_limit(): void if (false === $previous) { putenv(AvroDataIOReader::MAX_DECOMPRESS_LENGTH_ENV); } else { - putenv(AvroDataIOReader::MAX_DECOMPRESS_LENGTH_ENV . '=' . $previous); + putenv(AvroDataIOReader::MAX_DECOMPRESS_LENGTH_ENV.'='.$previous); } } } + protected function add_data_file(string $data_file): string + { + $data_file = "$data_file.".self::current_timestamp(); + $full = implode(DIRECTORY_SEPARATOR, [TEST_TEMP_DIR, $data_file]); + $this->dataFiles[] = $full; + + return $full; + } + protected function remove_data_files(): void { /** @phpstan-ignore booleanAnd.leftAlwaysTrue */ From fc78dc1a2226616966d8bdfc46eca8a462e498cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isma=C3=ABl=20Mej=C3=ADa?= Date: Sat, 11 Jul 2026 17:21:17 +0200 Subject: [PATCH 3/9] AVRO-4289: [php] Address review: autoload order, streaming inflate, snappy overflow - autoload.php: include AvroDataIOException before its subclass AvroDataIODecompressionSizeException (the previous order caused a fatal error under the include-based loader). - gzUncompress now streams via inflate_add in chunks: this bounds allocation incrementally and reports genuine inflate errors distinctly from an over-limit block (no more misclassifying corrupt data, and no maxLength+1 arithmetic that could overflow to a float near PHP_INT_MAX). - Replace snappyDeclaredLength with ensureSnappyWithinLimit, which caps the running varint against the limit and treats any 32-bit wrap as over-limit, so the guard holds on 32-bit builds. - Add snappy/zstandard/bzip2 decompression-limit tests (skipped when the extension is unavailable). Assisted-by: GitHub Copilot:claude-opus-4.8 --- lang/php/lib/DataFile/AvroDataIOReader.php | 66 ++++++++++++++-------- lang/php/lib/autoload.php | 2 +- lang/php/test/DataFileTest.php | 58 +++++++++++++++++++ 3 files changed, 103 insertions(+), 23 deletions(-) diff --git a/lang/php/lib/DataFile/AvroDataIOReader.php b/lang/php/lib/DataFile/AvroDataIOReader.php index 4666c094237..3610c44cd7f 100644 --- a/lang/php/lib/DataFile/AvroDataIOReader.php +++ b/lang/php/lib/DataFile/AvroDataIOReader.php @@ -44,6 +44,9 @@ class AvroDataIOReader public const MAX_DECOMPRESS_LENGTH_ENV = 'AVRO_MAX_DECOMPRESS_LENGTH'; + /** Chunk size, in bytes, used when streaming inflate so the output can be bounded incrementally. */ + private const INFLATE_CHUNK_SIZE = 8192; + public string $sync_marker; /** * @var array object container metadata @@ -260,16 +263,32 @@ private static function checkDecompressLength(int $length, int $maxLength): void private function gzUncompress(string $compressed): string { $maxLength = self::maxDecompressLength(); - // gzinflate caps its output at the given length: a block that would - // decompress to more than the limit yields false here without - // materializing the full (potentially huge) output. The '@' suppresses - // the "insufficient memory" notice zlib emits when the cap is hit. - $datum = @gzinflate($compressed, $maxLength + 1); + $context = inflate_init(ZLIB_ENCODING_RAW); + if (false === $context) { + throw new AvroException('gzip uncompression failed.'); + } - if (false === $datum) { - throw new AvroDataIODecompressionSizeException($maxLength); + // Inflate in chunks and check the running length after each step so an + // over-large (or malicious) block is rejected without materializing the + // full output, while genuine decompression errors (inflate_add === false) + // are reported distinctly. + $datum = ''; + $length = strlen($compressed); + for ($offset = 0; $offset < $length; $offset += self::INFLATE_CHUNK_SIZE) { + $piece = substr($compressed, $offset, self::INFLATE_CHUNK_SIZE); + $out = @inflate_add($context, $piece); + if (false === $out) { + throw new AvroException('gzip uncompression failed.'); + } + $datum .= $out; + self::checkDecompressLength(strlen($datum), $maxLength); } + $out = @inflate_add($context, '', ZLIB_FINISH); + if (false === $out) { + throw new AvroException('gzip uncompression failed.'); + } + $datum .= $out; self::checkDecompressLength(strlen($datum), $maxLength); return $datum; @@ -323,11 +342,9 @@ private function snappyUncompress(string $compressed): string } $maxLength = self::maxDecompressLength(); // The Snappy block header declares the uncompressed length as a varint; - // reject an over-large block before allocating for it. - $declared = self::snappyDeclaredLength(substr((string) $compressed, 0, -4)); - if (null !== $declared) { - self::checkDecompressLength($declared, $maxLength); - } + // reject an over-large block before allocating for it. Parsed with an + // early cap so it stays correct even if a 32-bit int would overflow. + self::ensureSnappyWithinLimit(substr((string) $compressed, 0, -4), $maxLength); $crc32 = unpack('N', substr((string) $compressed, -4))[1]; $datum = snappy_uncompress(substr((string) $compressed, 0, -4)); @@ -345,27 +362,32 @@ private function snappyUncompress(string $compressed): string } /** - * Return the uncompressed length declared in a raw Snappy block header, - * which prefixes the data as a little-endian base-128 varint. Returns null - * if the header cannot be parsed. + * Reject a Snappy block whose declared uncompressed length (a little-endian + * base-128 varint at the start of the block) exceeds $maxLength, before + * allocating for it. The running length is compared against the cap after + * every group, and any wrap to a negative value (32-bit int overflow) is + * treated as over the limit, so the guard holds on 32-bit builds too. + * + * @throws AvroDataIODecompressionSizeException if the declared length exceeds the limit */ - private static function snappyDeclaredLength(string $data): ?int + private static function ensureSnappyWithinLimit(string $data, int $maxLength): void { $result = 0; $shift = 0; $length = strlen($data); for ($i = 0; $i < $length; $i++) { $byte = ord($data[$i]); - $result |= ($byte & 0x7F) << $shift; + $result += ($byte & 0x7F) << $shift; + if ($result < 0 || $result > $maxLength) { + throw new AvroDataIODecompressionSizeException($maxLength); + } if (0 === ($byte & 0x80)) { - return $result; + return; // declared length is within the limit } $shift += 7; - if ($shift > 63) { - break; + if ($shift > 28) { + return; // more than 5 bytes: malformed; the post-decompress check will catch it } } - - return null; } } diff --git a/lang/php/lib/autoload.php b/lang/php/lib/autoload.php index 09b6c56c7c4..c563dc07243 100644 --- a/lang/php/lib/autoload.php +++ b/lang/php/lib/autoload.php @@ -27,8 +27,8 @@ include __DIR__.'/AvroUtil.php'; include __DIR__.'/DataFile/AvroDataIO.php'; -include __DIR__.'/DataFile/AvroDataIODecompressionSizeException.php'; include __DIR__.'/DataFile/AvroDataIOException.php'; +include __DIR__.'/DataFile/AvroDataIODecompressionSizeException.php'; include __DIR__.'/DataFile/AvroDataIOReader.php'; include __DIR__.'/DataFile/AvroDataIOWriter.php'; diff --git a/lang/php/test/DataFileTest.php b/lang/php/test/DataFileTest.php index 4ee9292cb23..85c47b16072 100644 --- a/lang/php/test/DataFileTest.php +++ b/lang/php/test/DataFileTest.php @@ -448,6 +448,30 @@ public function test_deflate_block_within_decompression_limit(): void } } + public function test_snappy_block_decompression_limit(): void + { + if (!extension_loaded('snappy')) { + $this->markTestSkipped('snappy extension not available'); + } + $this->assertCodecRejectsOversizedBlock(AvroDataIO::SNAPPY_CODEC); + } + + public function test_zstandard_block_decompression_limit(): void + { + if (!extension_loaded('zstd')) { + $this->markTestSkipped('zstd extension not available'); + } + $this->assertCodecRejectsOversizedBlock(AvroDataIO::ZSTANDARD_CODEC); + } + + public function test_bzip2_block_decompression_limit(): void + { + if (!extension_loaded('bz2')) { + $this->markTestSkipped('bz2 extension not available'); + } + $this->assertCodecRejectsOversizedBlock(AvroDataIO::BZIP2_CODEC); + } + protected function add_data_file(string $data_file): string { $data_file = "$data_file.".self::current_timestamp(); @@ -473,4 +497,38 @@ protected static function remove_data_file($data_file): void unlink($data_file); } } + + /** + * Write a single, highly compressible block with the given codec, then read + * it back with a small decompression limit and assert it is rejected. + */ + private function assertCodecRejectsOversizedBlock(string $codec): void + { + $data_file = $this->add_data_file(sprintf('data-decompress-limit-%s.avr', $codec)); + $dw = AvroDataIO::openFile($data_file, 'w', '"string"', $codec); + $dw->append(str_repeat('a', 64 * 1024)); // 64 KiB, compresses tiny + $dw->close(); + + $previous = getenv(AvroDataIOReader::MAX_DECOMPRESS_LENGTH_ENV); + putenv(AvroDataIOReader::MAX_DECOMPRESS_LENGTH_ENV.'=1024'); + + try { + $dr = AvroDataIO::openFile($data_file); + $thrown = false; + + try { + $dr->data(); + } catch (AvroDataIODecompressionSizeException $e) { + $thrown = true; + } + $dr->close(); + $this->assertTrue($thrown, sprintf('expected a decompression size exception for %s', $codec)); + } finally { + if (false === $previous) { + putenv(AvroDataIOReader::MAX_DECOMPRESS_LENGTH_ENV); + } else { + putenv(AvroDataIOReader::MAX_DECOMPRESS_LENGTH_ENV.'='.$previous); + } + } + } } From 0d743705654cd5a895176e2d1d655bc87a02dd3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isma=C3=ABl=20Mej=C3=ADa?= Date: Sat, 11 Jul 2026 19:05:29 +0200 Subject: [PATCH 4/9] AVRO-4289: [php] Reject overlong snappy varint; avoid quadratic inflate append - ensureSnappyWithinLimit now rejects a Snappy length header longer than five varint bytes as malformed instead of silently deferring it; a uint32 length never needs more than five bytes. - gzUncompress collects inflated chunks into an array and joins once at the end instead of repeatedly appending to a growing string, avoiding quadratic reallocation on large blocks. The running total is tracked separately so the per-chunk size check is unchanged. Assisted-by: GitHub Copilot:claude-opus-4.8 --- lang/php/lib/DataFile/AvroDataIOReader.php | 27 ++++++++++++++-------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/lang/php/lib/DataFile/AvroDataIOReader.php b/lang/php/lib/DataFile/AvroDataIOReader.php index 3610c44cd7f..ba56f84907b 100644 --- a/lang/php/lib/DataFile/AvroDataIOReader.php +++ b/lang/php/lib/DataFile/AvroDataIOReader.php @@ -271,8 +271,10 @@ private function gzUncompress(string $compressed): string // Inflate in chunks and check the running length after each step so an // over-large (or malicious) block is rejected without materializing the // full output, while genuine decompression errors (inflate_add === false) - // are reported distinctly. - $datum = ''; + // are reported distinctly. Pieces are collected and joined once at the + // end to avoid repeatedly reallocating a growing result string. + $pieces = []; + $total = 0; $length = strlen($compressed); for ($offset = 0; $offset < $length; $offset += self::INFLATE_CHUNK_SIZE) { $piece = substr($compressed, $offset, self::INFLATE_CHUNK_SIZE); @@ -280,18 +282,20 @@ private function gzUncompress(string $compressed): string if (false === $out) { throw new AvroException('gzip uncompression failed.'); } - $datum .= $out; - self::checkDecompressLength(strlen($datum), $maxLength); + $pieces[] = $out; + $total += strlen($out); + self::checkDecompressLength($total, $maxLength); } $out = @inflate_add($context, '', ZLIB_FINISH); if (false === $out) { throw new AvroException('gzip uncompression failed.'); } - $datum .= $out; - self::checkDecompressLength(strlen($datum), $maxLength); + $pieces[] = $out; + $total += strlen($out); + self::checkDecompressLength($total, $maxLength); - return $datum; + return implode('', $pieces); } /** @@ -366,9 +370,10 @@ private function snappyUncompress(string $compressed): string * base-128 varint at the start of the block) exceeds $maxLength, before * allocating for it. The running length is compared against the cap after * every group, and any wrap to a negative value (32-bit int overflow) is - * treated as over the limit, so the guard holds on 32-bit builds too. + * treated as over the limit, so the guard holds on 32-bit builds too. A + * varint longer than five bytes is malformed and is rejected as well. * - * @throws AvroDataIODecompressionSizeException if the declared length exceeds the limit + * @throws AvroException if the declared length exceeds the limit or is malformed */ private static function ensureSnappyWithinLimit(string $data, int $maxLength): void { @@ -386,7 +391,9 @@ private static function ensureSnappyWithinLimit(string $data, int $maxLength): v } $shift += 7; if ($shift > 28) { - return; // more than 5 bytes: malformed; the post-decompress check will catch it + // A Snappy uncompressed length is a uint32, encoded in at most + // five varint bytes; a longer encoding is malformed. + throw new AvroException('snappy uncompression failed - malformed length header.'); } } } From 1fe9871cb428c94cf1d71b407e6d060e71618f0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isma=C3=ABl=20Mej=C3=ADa?= Date: Sat, 11 Jul 2026 19:38:18 +0200 Subject: [PATCH 5/9] AVRO-4289: [php] Guard short Snappy blocks and truncated length headers - snappyUncompress() now rejects a block shorter than the 4-byte CRC trailer and checks unpack()'s result, instead of slicing with negative offsets and reading [1] off a false return (which would raise a warning/fatal). The payload slice is computed once and reused for the precheck and snappy_uncompress(). - ensureSnappyWithinLimit() now rejects a length varint that never terminates (input ends with all continuation bits set) as malformed, rather than falling through and silently succeeding, so a truncated header cannot bypass the guard. Assisted-by: GitHub Copilot:claude-opus-4.8 --- lang/php/lib/DataFile/AvroDataIOReader.php | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/lang/php/lib/DataFile/AvroDataIOReader.php b/lang/php/lib/DataFile/AvroDataIOReader.php index ba56f84907b..2e4a7e5e98f 100644 --- a/lang/php/lib/DataFile/AvroDataIOReader.php +++ b/lang/php/lib/DataFile/AvroDataIOReader.php @@ -345,12 +345,23 @@ private function snappyUncompress(string $compressed): string throw new AvroException('Please install ext-snappy to use snappy compression.'); } $maxLength = self::maxDecompressLength(); + // The block is a Snappy payload followed by a 4-byte CRC32 trailer; a + // shorter block is malformed and must not be sliced with negative + // offsets (which would make unpack() return false below). + if (strlen($compressed) < 4) { + throw new AvroException('snappy uncompression failed - block too small.'); + } + $payload = substr($compressed, 0, -4); + $unpacked = unpack('N', substr($compressed, -4)); + if (false === $unpacked) { + throw new AvroException('snappy uncompression failed - missing crc32 trailer.'); + } + $crc32 = $unpacked[1]; // The Snappy block header declares the uncompressed length as a varint; // reject an over-large block before allocating for it. Parsed with an // early cap so it stays correct even if a 32-bit int would overflow. - self::ensureSnappyWithinLimit(substr((string) $compressed, 0, -4), $maxLength); - $crc32 = unpack('N', substr((string) $compressed, -4))[1]; - $datum = snappy_uncompress(substr((string) $compressed, 0, -4)); + self::ensureSnappyWithinLimit($payload, $maxLength); + $datum = snappy_uncompress($payload); if (false === $datum) { throw new AvroException('snappy uncompression failed.'); @@ -396,5 +407,10 @@ private static function ensureSnappyWithinLimit(string $data, int $maxLength): v throw new AvroException('snappy uncompression failed - malformed length header.'); } } + + // The data ended before a terminating byte (top bit clear) was seen, so + // the length header is truncated/malformed; reject it rather than + // letting a malformed block bypass the guard. + throw new AvroException('snappy uncompression failed - truncated length header.'); } } From e7bdb4abcc3a5c6769e174abbde0d59111448ab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isma=C3=ABl=20Mej=C3=ADa?= Date: Sat, 11 Jul 2026 19:58:55 +0200 Subject: [PATCH 6/9] AVRO-4289: [php] Clarify gzUncompress chunk-inflation comment Reword the comment so it no longer implies the full output is never materialized: each inflated chunk is materialized, but the running total is checked after every chunk so an over-large block is rejected before its whole output is accumulated. Assisted-by: GitHub Copilot:claude-opus-4.8 --- lang/php/lib/DataFile/AvroDataIOReader.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/lang/php/lib/DataFile/AvroDataIOReader.php b/lang/php/lib/DataFile/AvroDataIOReader.php index 2e4a7e5e98f..204006c2ffc 100644 --- a/lang/php/lib/DataFile/AvroDataIOReader.php +++ b/lang/php/lib/DataFile/AvroDataIOReader.php @@ -268,11 +268,12 @@ private function gzUncompress(string $compressed): string throw new AvroException('gzip uncompression failed.'); } - // Inflate in chunks and check the running length after each step so an - // over-large (or malicious) block is rejected without materializing the - // full output, while genuine decompression errors (inflate_add === false) - // are reported distinctly. Pieces are collected and joined once at the - // end to avoid repeatedly reallocating a growing result string. + // Inflate in chunks and check the running total after each step so an + // over-large (or malicious) block is rejected before its whole output is + // accumulated (each inflated chunk is still materialized), while genuine + // decompression errors (inflate_add === false) are reported distinctly. + // Pieces are collected and joined once at the end to avoid repeatedly + // reallocating a growing result string. $pieces = []; $total = 0; $length = strlen($compressed); From 6d58380cfa0c428e06658915cacbac494753c5d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isma=C3=ABl=20Mej=C3=ADa?= Date: Mon, 13 Jul 2026 01:42:22 +0200 Subject: [PATCH 7/9] AVRO-4289: [php] Correct deflate error wording; ensure reader is closed in tests Address review feedback: - The raw-deflate (ZLIB_ENCODING_RAW) decompression path reported "gzip uncompression failed"; reword to "deflate uncompression failed" to match the codec. - The decompression-limit tests only closed the reader on the happy path, so an unexpected exception from $dr->data() would leak the file handle. Wrap the reader usage in try/finally (closing in finally) and fail explicitly with $this->fail() when the expected exception is not thrown, instead of the $thrown flag. Assisted-by: GitHub Copilot:claude-opus-4.8 --- lang/php/lib/DataFile/AvroDataIOReader.php | 6 ++--- lang/php/test/DataFileTest.php | 26 +++++++++++++--------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/lang/php/lib/DataFile/AvroDataIOReader.php b/lang/php/lib/DataFile/AvroDataIOReader.php index 204006c2ffc..e9d988aa62b 100644 --- a/lang/php/lib/DataFile/AvroDataIOReader.php +++ b/lang/php/lib/DataFile/AvroDataIOReader.php @@ -265,7 +265,7 @@ private function gzUncompress(string $compressed): string $maxLength = self::maxDecompressLength(); $context = inflate_init(ZLIB_ENCODING_RAW); if (false === $context) { - throw new AvroException('gzip uncompression failed.'); + throw new AvroException('deflate uncompression failed.'); } // Inflate in chunks and check the running total after each step so an @@ -281,7 +281,7 @@ private function gzUncompress(string $compressed): string $piece = substr($compressed, $offset, self::INFLATE_CHUNK_SIZE); $out = @inflate_add($context, $piece); if (false === $out) { - throw new AvroException('gzip uncompression failed.'); + throw new AvroException('deflate uncompression failed.'); } $pieces[] = $out; $total += strlen($out); @@ -290,7 +290,7 @@ private function gzUncompress(string $compressed): string $out = @inflate_add($context, '', ZLIB_FINISH); if (false === $out) { - throw new AvroException('gzip uncompression failed.'); + throw new AvroException('deflate uncompression failed.'); } $pieces[] = $out; $total += strlen($out); diff --git a/lang/php/test/DataFileTest.php b/lang/php/test/DataFileTest.php index 85c47b16072..880e7183473 100644 --- a/lang/php/test/DataFileTest.php +++ b/lang/php/test/DataFileTest.php @@ -405,15 +405,15 @@ public function test_deflate_block_decompression_limit(): void try { $dr = AvroDataIO::openFile($data_file); - $thrown = false; try { $dr->data(); + $this->fail('expected a decompression size exception'); } catch (AvroDataIODecompressionSizeException $e) { - $thrown = true; + // expected + } finally { + $dr->close(); } - $dr->close(); - $this->assertTrue($thrown, 'expected a decompression size exception'); } finally { if (false === $previous) { putenv(AvroDataIOReader::MAX_DECOMPRESS_LENGTH_ENV); @@ -436,9 +436,13 @@ public function test_deflate_block_within_decompression_limit(): void try { $dr = AvroDataIO::openFile($data_file); - $data = $dr->data(); - $dr->close(); - $this->assertSame([$payload], $data); + + try { + $data = $dr->data(); + $this->assertSame([$payload], $data); + } finally { + $dr->close(); + } } finally { if (false === $previous) { putenv(AvroDataIOReader::MAX_DECOMPRESS_LENGTH_ENV); @@ -514,15 +518,15 @@ private function assertCodecRejectsOversizedBlock(string $codec): void try { $dr = AvroDataIO::openFile($data_file); - $thrown = false; try { $dr->data(); + $this->fail(sprintf('expected a decompression size exception for %s', $codec)); } catch (AvroDataIODecompressionSizeException $e) { - $thrown = true; + // expected + } finally { + $dr->close(); } - $dr->close(); - $this->assertTrue($thrown, sprintf('expected a decompression size exception for %s', $codec)); } finally { if (false === $previous) { putenv(AvroDataIOReader::MAX_DECOMPRESS_LENGTH_ENV); From d10c764a1c00efb2f8615d8d7dd620397291752c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isma=C3=ABl=20Mej=C3=ADa?= Date: Mon, 13 Jul 2026 02:12:53 +0200 Subject: [PATCH 8/9] AVRO-4289: [php] Compare snappy CRC32 in a common unsigned representation On 32-bit PHP unpack('N', ...) can yield a float (> PHP_INT_MAX) while crc32() returns a possibly-negative int, so the strict comparison reported false crc32 mismatches for valid data. Compare both via sprintf('%u', ...) so the check is correct regardless of platform int width. Assisted-by: GitHub Copilot:claude-opus-4.8 --- lang/php/lib/DataFile/AvroDataIOReader.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lang/php/lib/DataFile/AvroDataIOReader.php b/lang/php/lib/DataFile/AvroDataIOReader.php index e9d988aa62b..1fa9ac50dc2 100644 --- a/lang/php/lib/DataFile/AvroDataIOReader.php +++ b/lang/php/lib/DataFile/AvroDataIOReader.php @@ -370,7 +370,11 @@ private function snappyUncompress(string $compressed): string self::checkDecompressLength(strlen($datum), $maxLength); - if ($crc32 !== crc32($datum)) { + // Compare the CRC32 values in a common unsigned representation: + // unpack('N') can yield a float (> PHP_INT_MAX) on 32-bit PHP while + // crc32() yields a (possibly negative) int, so a strict/int comparison + // would report false mismatches for valid data. + if (sprintf('%u', $crc32) !== sprintf('%u', crc32($datum))) { throw new AvroException('snappy uncompression failed - crc32 mismatch.'); } From 8171b892d4f17710bb1338f850515d76e4d663fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isma=C3=ABl=20Mej=C3=ADa?= Date: Mon, 13 Jul 2026 02:42:20 +0200 Subject: [PATCH 9/9] AVRO-4289: [php] Drop error-suppression on inflate_add Remove the @ operator from the two inflate_add() calls in the deflate path. The code already checks for a false return and throws, so suppression only hides zlib warnings that would help diagnose corrupt input, and it's inconsistent with the other codec helpers. Assisted-by: GitHub Copilot:claude-opus-4.8 --- lang/php/lib/DataFile/AvroDataIOReader.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lang/php/lib/DataFile/AvroDataIOReader.php b/lang/php/lib/DataFile/AvroDataIOReader.php index 1fa9ac50dc2..040227a3b2d 100644 --- a/lang/php/lib/DataFile/AvroDataIOReader.php +++ b/lang/php/lib/DataFile/AvroDataIOReader.php @@ -279,7 +279,7 @@ private function gzUncompress(string $compressed): string $length = strlen($compressed); for ($offset = 0; $offset < $length; $offset += self::INFLATE_CHUNK_SIZE) { $piece = substr($compressed, $offset, self::INFLATE_CHUNK_SIZE); - $out = @inflate_add($context, $piece); + $out = inflate_add($context, $piece); if (false === $out) { throw new AvroException('deflate uncompression failed.'); } @@ -288,7 +288,7 @@ private function gzUncompress(string $compressed): string self::checkDecompressLength($total, $maxLength); } - $out = @inflate_add($context, '', ZLIB_FINISH); + $out = inflate_add($context, '', ZLIB_FINISH); if (false === $out) { throw new AvroException('deflate uncompression failed.'); }