Skip to content
Open
40 changes: 40 additions & 0 deletions lang/php/lib/DataFile/AvroDataIODecompressionSizeException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

declare(strict_types=1);

namespace Apache\Avro\DataFile;

/**
* Raised when a data-file block decompresses to more than the configured maximum.
*
* A block with a very high compression ratio (or a malformed block) can expand
* to far more memory than its compressed size; this exception guards against
* unbounded memory allocation while reading such a block.
*/
class AvroDataIODecompressionSizeException extends AvroDataIOException
{
public function __construct(int $maxLength)
{
parent::__construct(
sprintf('Decompressed block size exceeds the maximum allowed of %d bytes.', $maxLength)
);
}
}
143 changes: 136 additions & 7 deletions lang/php/lib/DataFile/AvroDataIOReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,20 @@
*/
class AvroDataIOReader
{
/**
* Default upper bound, in bytes, on the size a single data-file block may
* decompress to. A block with a very high compression ratio (or a malformed
* block) can otherwise expand to far more memory than its compressed size.
* Mirrors the Java SDK's decompression limit (AVRO-4247). Overridable with
* the AVRO_MAX_DECOMPRESS_LENGTH environment variable.
*/
public const DEFAULT_MAX_DECOMPRESS_LENGTH = 209715200; // 200 MiB

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<string, mixed> object container metadata
Expand Down Expand Up @@ -220,18 +234,69 @@ 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();
$context = inflate_init(ZLIB_ENCODING_RAW);
if (false === $context) {
throw new AvroException('deflate uncompression failed.');
}
Comment on lines 263 to +269

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.

Updated the PR description — it now says the deflate codec inflates in bounded chunks via inflate_init/inflate_add and rejects the block as soon as the accumulated output exceeds the limit, matching the implementation (no gzinflate).


if (false === $datum) {
throw new AvroException('gzip uncompression failed.');
// 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);
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('deflate uncompression failed.');
}
$pieces[] = $out;
$total += strlen($out);
self::checkDecompressLength($total, $maxLength);
}

return $datum;
$out = inflate_add($context, '', ZLIB_FINISH);
if (false === $out) {
throw new AvroException('deflate uncompression failed.');
}
$pieces[] = $out;
$total += strlen($out);
self::checkDecompressLength($total, $maxLength);

return implode('', $pieces);
}

/**
Expand All @@ -248,6 +313,8 @@ private function zstdUncompress(string $compressed): string
throw new AvroException('zstd uncompression failed.');
}

self::checkDecompressLength(strlen($datum), self::maxDecompressLength());

return $datum;
}

Expand All @@ -265,6 +332,8 @@ private function bzUncompress(string $compressed): string
throw new AvroException('bz2 uncompression failed.');
}

self::checkDecompressLength(strlen($datum), self::maxDecompressLength());

return $datum;
}

Expand All @@ -276,17 +345,77 @@ private function snappyUncompress(string $compressed): string
if (!extension_loaded('snappy')) {
throw new AvroException('Please install ext-snappy to use snappy compression.');
}
$crc32 = unpack('N', substr((string) $compressed, -4))[1];
$datum = snappy_uncompress(substr((string) $compressed, 0, -4));
$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($payload, $maxLength);
$datum = snappy_uncompress($payload);

if (false === $datum) {
throw new AvroException('snappy uncompression failed.');
}

if ($crc32 !== crc32($datum)) {
self::checkDecompressLength(strlen($datum), $maxLength);

// 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.');
}

return $datum;
}

/**
* 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. A
* varint longer than five bytes is malformed and is rejected as well.
*
* @throws AvroException if the declared length exceeds the limit or is malformed
*/
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;
if ($result < 0 || $result > $maxLength) {
throw new AvroDataIODecompressionSizeException($maxLength);
}
if (0 === ($byte & 0x80)) {
return; // declared length is within the limit
}
$shift += 7;
if ($shift > 28) {
// 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.');
}
}

// 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.');
}
}
1 change: 1 addition & 0 deletions lang/php/lib/autoload.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

include __DIR__.'/DataFile/AvroDataIO.php';
include __DIR__.'/DataFile/AvroDataIOException.php';
include __DIR__.'/DataFile/AvroDataIODecompressionSizeException.php';
include __DIR__.'/DataFile/AvroDataIOReader.php';
include __DIR__.'/DataFile/AvroDataIOWriter.php';

Expand Down
124 changes: 124 additions & 0 deletions lang/php/test/DataFileTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -386,6 +388,94 @@ public function test_differing_schemas_with_complex_objects(): void
}
}

/**
* 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
Comment on lines +391 to +396

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 fc78dc1 — added snappy/zstandard/bzip2 decompression-limit tests (skipped when the extension is unavailable).

{
$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);

try {
$dr->data();
$this->fail('expected a decompression size exception');
} catch (AvroDataIODecompressionSizeException $e) {
// expected
} finally {
$dr->close();
}
} 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);

try {
$data = $dr->data();
$this->assertSame([$payload], $data);
} finally {
$dr->close();
}
} finally {
if (false === $previous) {
putenv(AvroDataIOReader::MAX_DECOMPRESS_LENGTH_ENV);
} else {
putenv(AvroDataIOReader::MAX_DECOMPRESS_LENGTH_ENV.'='.$previous);
}
}
}

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();
Expand All @@ -411,4 +501,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);

try {
$dr->data();
$this->fail(sprintf('expected a decompression size exception for %s', $codec));
} catch (AvroDataIODecompressionSizeException $e) {
// expected
} finally {
$dr->close();
}
} finally {
if (false === $previous) {
putenv(AvroDataIOReader::MAX_DECOMPRESS_LENGTH_ENV);
} else {
putenv(AvroDataIOReader::MAX_DECOMPRESS_LENGTH_ENV.'='.$previous);
}
}
}
}
Loading