Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
5337449
AVRO-4288: [perl] Enforce a maximum decompressed block size
iemejia Jul 11, 2026
aa239b8
AVRO-4288: [perl] Address review: decouple block_max_size; bound zstd…
iemejia Jul 11, 2026
985cabc
AVRO-4288: [perl] Drop unused bunzip2 import
iemejia Jul 11, 2026
d924bc2
AVRO-4288: [perl] Fail closed on zstd decompress failure; test bzip2/…
iemejia Jul 11, 2026
f3d866e
AVRO-4288: [perl] Enforce the decompress limit on bytes, not characters
iemejia Jul 11, 2026
cbba52a
AVRO-4288: [perl] Bound compressed block read; check in-memory open
iemejia Jul 11, 2026
ccab12c
AVRO-4288: [perl] Skip zstd tests on the exact module the reader needs
iemejia Jul 11, 2026
5b1ba03
AVRO-4288: [perl] Lazy-load zstandard decompressor
iemejia Jul 11, 2026
f917988
AVRO-4288: [perl] Check zstd size before concat; distinct compressed-…
iemejia Jul 11, 2026
31f054c
AVRO-4288: [perl] Require Compress::Zstd 0.10; verify block read length
iemejia Jul 11, 2026
dc8de86
AVRO-4288: [perl] Include underlying error and size in decompression …
iemejia Jul 12, 2026
aa45cf0
AVRO-4288: [perl] Extract block_size local in the compressed-size mes…
iemejia Jul 12, 2026
dd7c9e8
AVRO-4288: [perl] Reject negative object_count/block_size; clarify co…
iemejia Jul 13, 2026
55827e0
AVRO-4288: [perl] Read data-file block in bounded chunks
iemejia Jul 13, 2026
308ccea
AVRO-4288: [perl] Byte-count the block read; bound zstd per-call output
iemejia Jul 13, 2026
7f0e2e0
AVRO-4288: [perl] Localize $@ when probing for the zstd decompressor
iemejia Jul 13, 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
2 changes: 1 addition & 1 deletion lang/perl/Makefile.PL
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ test_requires 'Test::Exception';
test_requires 'Test::More', 0.88;
test_requires 'Test::Pod';
requires 'Compress::Zlib';
requires 'Compress::Zstd';
requires 'Compress::Zstd', '0.10'; # 0.10 first provides the streaming Compress::Zstd::Decompressor
requires 'Encode';
requires 'Error::Simple';
requires 'JSON::MaybeXS';
Expand Down
196 changes: 184 additions & 12 deletions lang/perl/lib/Avro/DataFileReader.pm
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,22 @@

use constant MARKER_SIZE => 16;

# TODO: refuse to read a block more than block_max_size, instead
# do partial reads
# A data-file block is decompressed according to the file's codec. A block with
# a very high compression ratio (or a malformed block) can expand to far more
# memory than its compressed size. To guard against unbounded allocation, the
# decompressed size of a single block is capped. This mirrors the Java SDK's
# decompression limit (AVRO-4247). The default can be overridden with the
# AVRO_MAX_DECOMPRESS_LENGTH environment variable.
use constant DEFAULT_MAX_DECOMPRESS_LENGTH => 200 * 1024 * 1024; # 200 MiB

use Avro::DataFile;
use Avro::BinaryDecoder;
use Avro::Schema;
use Carp;
use Compress::Zstd;
use IO::Uncompress::Bunzip2 qw(bunzip2);
use IO::Uncompress::Bunzip2 ();
use IO::Uncompress::RawInflate ;
use Fcntl();
use bytes ();

our $VERSION = '++MODULE_VERSION++';

Expand Down Expand Up @@ -203,36 +208,193 @@
$datafile->{block_size} = Avro::BinaryDecoder->decode_long(
undef, undef, $fh,
);
## Both are Avro long (zigzag) values, so a malformed/truncated file can
## yield negatives. A negative block_size would flow into $want and a
## negative-length read; a negative object_count is equally nonsensical.
## Reject both before first use.
if ($datafile->{object_count} < 0) {
croak "Invalid negative object count: $datafile->{object_count}";
}
if ($datafile->{block_size} < 0) {
croak "Invalid negative block size: $datafile->{block_size}";
}
$datafile->{block_start} = tell $fh;

return if $codec eq 'null';

## we need to read the entire block into memory, to inflate it
my $nread = read $fh, my $block, $datafile->{block_size} + MARKER_SIZE
or croak "Error reading from file: $!";
## Guard against an attacker-controlled block_size triggering a huge
## allocation for the compressed block itself, before any decompression
## happens. When the reader is configured with block_max_size, reject a
## block whose declared compressed size exceeds that bound up front.
my $block_max = $datafile->{block_max_size};
my $block_size = $datafile->{block_size};
if (defined $block_max && $block_size > $block_max) {
Avro::DataFile::Error::CompressedBlockSize->throw(
"Compressed block size $block_size exceeds the configured block_max_size of $block_max bytes"
);
Comment on lines +229 to +234

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. The pre-read guard now throws a distinct Avro::DataFile::Error::CompressedBlockSize (about the compressed block size), kept separate from DecompressionSize (the decompressed-size cap), so callers can distinguish the two conditions. Pushed in f917988.

}

## we need to read the entire block into memory, to inflate it. Read in
## bounded chunks (rather than a single read of $want bytes, which would
## pre-extend the buffer to the attacker-controlled block_size before a short
## read is detected) and verify the exact byte count afterward: a short read
## (truncated/malformed file) would otherwise slip through and surface later
## as a confusing marker/decompressor error.
my $want = $datafile->{block_size} + MARKER_SIZE;
my $block = '';
my $chunk_size = 64 * 1024;
while (bytes::length($block) < $want) {
my $need = $want - bytes::length($block);
$need = $chunk_size if $need > $chunk_size;
my $nread = read $fh, my $buf, $need;

Check failure on line 249 in lang/perl/lib/Avro/DataFileReader.pm

View workflow job for this annotation

GitHub Actions / interop (ubuntu-24.04-arm, 5.32)

Parentheses missing around "my" list

Check failure on line 249 in lang/perl/lib/Avro/DataFileReader.pm

View workflow job for this annotation

GitHub Actions / interop (ubuntu-latest, 5.32)

Parentheses missing around "my" list
if (!defined $nread) {
croak "Error reading from file: $!";
}
last if $nread == 0; # EOF
$block .= $buf;
}
if (bytes::length($block) != $want) {
croak "Short read: expected $want bytes for the block, got "
. bytes::length($block) . " (truncated file?)";
}

## remove the marker
my $marker = substr $block, -(MARKER_SIZE), MARKER_SIZE, '';
$datafile->{block_marker} = $marker;

## The decompressed size of a block is capped to guard against a block with
## a very high compression ratio expanding to far more memory than its
## compressed size. The limit is the AVRO_MAX_DECOMPRESS_LENGTH environment
## variable, or DEFAULT_MAX_DECOMPRESS_LENGTH. (Note: block_max_size is a
## writer-side flush threshold measured in compressed bytes; it is used above
## as a compressed-size pre-read guard, but is not reused here for the
## decompressed-size cap, to avoid conflating the two units.)
my $limit = _max_decompress_length();
Comment on lines +265 to +272

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.

Addressed. When the reader is configured with block_max_size, a block whose declared compressed size exceeds it is now rejected before the compressed block is read into memory, guarding against an attacker-controlled block_size allocation. (Without block_max_size configured, the read still relies on the file's actual length; the decompressed-size cap remains the guard against expansion.) Added a test. Pushed in cbba52a.


Comment on lines +265 to +273

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 aa239b8 — the reader no longer reuses block_max_size (a writer-side compressed-block flush threshold) for the decompressed-size cap; the limit is the AVRO_MAX_DECOMPRESS_LENGTH env var / DEFAULT_MAX_DECOMPRESS_LENGTH.

Comment on lines +265 to +273

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: the limit is driven by AVRO_MAX_DECOMPRESS_LENGTH (default 200 MiB) and is independent of block_max_size (which bounds buffered compressed input, not decompressed size). The description now also reflects that deflate, bzip2, and zstandard are all covered.

## this is our new reader
$datafile->{reader} = do {
if ($codec eq 'deflate') {
IO::Uncompress::RawInflate->new(\$block);
my $z = IO::Uncompress::RawInflate->new(\$block)
or croak "Error inflating block: $IO::Uncompress::RawInflate::RawInflateError";
my $uncompressed = _inflate_bounded($z, $limit);
_open_decompressed(\$uncompressed);
}
elsif ($codec eq 'bzip2') {
my $uncompressed;
bunzip2 \$block => \$uncompressed;
do { open $fh, '<', \$uncompressed; $fh };
my $z = IO::Uncompress::Bunzip2->new(\$block)
or croak "Error decompressing bzip2 block: $IO::Uncompress::Bunzip2::Bunzip2Error";
my $uncompressed = _inflate_bounded($z, $limit);
_open_decompressed(\$uncompressed);
}
elsif ($codec eq 'zstandard') {
do { open $fh, '<', \(decompress(\$block)); $fh };
my $uncompressed = _zstd_decompress_bounded(\$block, $limit);
_open_decompressed(\$uncompressed);
}
};

return;
}

## Open an in-memory read handle over the decompressed block, surfacing any
## failure via croak rather than leaving $datafile->{reader} undefined (which
## would fail later with a less clear error). The handle keeps a reference to
## the scalar, so the caller's buffer stays alive for the lifetime of the read.
sub _open_decompressed {
my ($uncompressed_ref) = @_;
open my $fh, '<', $uncompressed_ref
or croak "Error opening decompressed block for reading: $!";
return $fh;
}

## Read from a streaming decompressor in chunks, rejecting the block as soon as
## its decompressed size would exceed $limit so an over-large (or malicious)
## block is not fully materialized in memory. Each read is sized to the
## remaining budget (capped at 64 KiB) so the buffer overshoots $limit by at
## most one byte before the check fires.
sub _inflate_bounded {
my ($z, $limit) = @_;
my $uncompressed = '';
my $chunk;
my $status;
while (1) {
my $budget = $limit - bytes::length($uncompressed) + 1;
my $to_read = $budget < 65536 ? $budget : 65536;
$status = $z->read($chunk, $to_read);
last unless defined $status && $status > 0;
$uncompressed .= $chunk;
_check_decompress_length(bytes::length($uncompressed), $limit);
}
if (!defined $status || $status < 0) {
croak "Error decompressing block: " . $z->error;
}
return $uncompressed;
}

## Streaming zstandard decompression, bounded the same way as _inflate_bounded so
## a high-ratio block is rejected before its full form is materialized.
sub _zstd_decompress_bounded {
my ($block_ref, $limit) = @_;
# Load the zstandard decompressor lazily so the reader still loads and works
# for other codecs when Compress::Zstd::Decompressor is unavailable (e.g. an
# older Compress::Zstd distribution that lacks the Decompressor submodule).
# Localize $@ so probing for the module cannot clobber a caller's $@, and
# capture the require error before building the message.
my $require_error;
unless (do { local $@; my $ok = eval { require Compress::Zstd::Decompressor; 1 }; $require_error = $@; $ok }) {
Avro::DataFile::Error::UnsupportedCodec->throw(
"Cannot read zstandard-compressed block: Compress::Zstd::Decompressor is not available: $require_error"
);
}
Comment on lines +336 to +346

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 both ways. Compress::Zstd::Decompressor (the streaming interface) first shipped in Compress::Zstd 0.10, so the prerequisite in Makefile.PL is now 'requires Compress::Zstd, 0.10'. As a belt-and-suspenders for portability, the zstandard case in t/04_datafile.t now SKIPs when Compress::Zstd::Decompressor is unavailable. Pushed in 31f054c.

my $decompressor = Compress::Zstd::Decompressor->new;
my $uncompressed = '';
my $length = bytes::length($$block_ref);
my $offset = 0;
# Feed the compressed input in small pieces. Compress::Zstd's streaming
# decompress() has no max-output parameter, so a single call materializes all
# output produced from the input it is handed; keeping each fed piece small
# bounds the transient $out (roughly one zstd internal block, ~128 KiB) so a
# highly-compressible frame cannot balloon a single call's output to
# gigabytes before the size check below runs.
my $piece_size = 16 * 1024;
while ($offset < $length) {
my $piece = substr($$block_ref, $offset, $piece_size);
$offset += $piece_size;
my $out = $decompressor->decompress($piece);
# The streaming decompressor croaks on a corrupt frame and otherwise
# emits all output produced while consuming the input it is given (there
# is no separate flush step in this API). Treat an undefined return as a
# failure and fail closed, rather than silently skipping it, so a
# malformed block cannot masquerade as a short, within-limit result.
unless (defined $out) {
croak "Error decompressing zstandard block";
}
# Check the prospective total before growing $uncompressed so a single
# large decompressed chunk cannot transiently balloon memory past the
# limit (or double peak memory from string reallocation).
_check_decompress_length(
bytes::length($uncompressed) + bytes::length($out), $limit);
$uncompressed .= $out;
Comment on lines +361 to +375

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.

Mitigated — Compress::Zstd's streaming decompress() has no max-output parameter, so the smallest guarantee is one call's output. The input is now fed in 16 KiB pieces (down from 64 KiB) to bound each call's transient $out (~one internal block), and the running total is still checked before concatenating into $uncompressed, so peak stays at uncompressed + one bounded piece.

}
return $uncompressed;
}

sub _check_decompress_length {
my ($length, $limit) = @_;
if ($length > $limit) {
Avro::DataFile::Error::DecompressionSize->throw(
"Decompressed block size $length exceeds the maximum allowed of $limit bytes"
);
}
return;
}

sub _max_decompress_length {
my $value = $ENV{AVRO_MAX_DECOMPRESS_LENGTH};
if (defined $value && $value =~ /\A[0-9]+\z/ && $value > 0) {
return $value + 0;
}
return DEFAULT_MAX_DECOMPRESS_LENGTH;
}

sub verify_marker {
my $datafile = shift;

Expand Down Expand Up @@ -308,4 +470,14 @@
package Avro::DataFile::Error::UnsupportedCodec;
use parent 'Error::Simple';

package Avro::DataFile::Error::DecompressionSize;
use parent 'Error::Simple';

## Raised when a block's declared *compressed* size exceeds the reader's
## configured block_max_size, before the block is read into memory. Kept
## distinct from DecompressionSize (which is about the *decompressed* size cap)
## so callers can tell the two conditions apart.
package Avro::DataFile::Error::CompressedBlockSize;
use parent 'Error::Simple';

1;
49 changes: 27 additions & 22 deletions lang/perl/t/04_datafile.t
Original file line number Diff line number Diff line change
Expand Up @@ -148,31 +148,36 @@ is_deeply $all[0], $data, "Our data is intact!";


## zstandard!
$zfh = File::Temp->new(UNLINK => 0);
$write_file = Avro::DataFileWriter->new(
fh => $zfh,
writer_schema => $schema,
codec => 'zstandard',
metadata => {
some => 'metadata',
},
);
$write_file->print($data);
$write_file->flush;
SKIP: {
eval { require Compress::Zstd::Decompressor; 1 }
or skip 'Compress::Zstd::Decompressor not available', 4;

## rewind
seek $zfh, 0, 0;
$zfh = File::Temp->new(UNLINK => 0);
$write_file = Avro::DataFileWriter->new(
fh => $zfh,
writer_schema => $schema,
codec => 'zstandard',
metadata => {
some => 'metadata',
},
);
$write_file->print($data);
$write_file->flush;

$read_file = Avro::DataFileReader->new(
fh => $zfh,
reader_schema => $schema,
);
is $read_file->metadata->{'avro.codec'}, 'zstandard', 'avro.codec';
is $read_file->metadata->{'some'}, 'metadata', 'custom meta';
## rewind
seek $zfh, 0, 0;

@all = $read_file->all;
is scalar @all, 1, "one object back";
is_deeply $all[0], $data, "Our data is intact!";
$read_file = Avro::DataFileReader->new(
fh => $zfh,
reader_schema => $schema,
);
is $read_file->metadata->{'avro.codec'}, 'zstandard', 'avro.codec';
is $read_file->metadata->{'some'}, 'metadata', 'custom meta';

@all = $read_file->all;
is scalar @all, 1, "one object back";
is_deeply $all[0], $data, "Our data is intact!";
}
}

## Test on a slightly larger file with 100 records
Expand Down
Loading
Loading