From 5337449f279ccb2db9bda7f44024a23360decf51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isma=C3=ABl=20Mej=C3=ADa?= Date: Sat, 11 Jul 2026 14:47:44 +0200 Subject: [PATCH 01/16] AVRO-4288: [perl] 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, bzip2 and zstandard codecs, mirroring the Java SDK's decompression limit (AVRO-4247): deflate and bzip2 are inflated in chunks and bounded before the full output is materialized. The limit uses the existing (previously unenforced) block_max_size attribute if set, otherwise defaults to 200 MiB and can be overridden with the AVRO_MAX_DECOMPRESS_LENGTH environment variable; exceeding it throws Avro::DataFile::Error::DecompressionSize. Assisted-by: GitHub Copilot:claude-opus-4.8 --- lang/perl/lib/Avro/DataFileReader.pm | 71 +++++++++++++++-- lang/perl/t/07_datafile_decompress_limit.t | 93 ++++++++++++++++++++++ 2 files changed, 157 insertions(+), 7 deletions(-) create mode 100644 lang/perl/t/07_datafile_decompress_limit.t diff --git a/lang/perl/lib/Avro/DataFileReader.pm b/lang/perl/lib/Avro/DataFileReader.pm index 0ef5f02cff5..b480dd196a9 100644 --- a/lang/perl/lib/Avro/DataFileReader.pm +++ b/lang/perl/lib/Avro/DataFileReader.pm @@ -28,8 +28,14 @@ use Object::Tiny qw{ 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 per reader via +# the block_max_size attribute, or globally 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; @@ -215,24 +221,72 @@ sub read_block_header { 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. + my $limit = $datafile->block_max_size; + $limit = _max_decompress_length() unless defined $limit; + ## 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); + do { open my $fh, '<', \$uncompressed; $fh }; } 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); + do { open my $fh, '<', \$uncompressed; $fh }; } elsif ($codec eq 'zstandard') { - do { open $fh, '<', \(decompress(\$block)); $fh }; + my $uncompressed = decompress(\$block); + _check_decompress_length(length($uncompressed), $limit); + do { open my $fh, '<', \$uncompressed; $fh }; } }; return; } +## 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. +sub _inflate_bounded { + my ($z, $limit) = @_; + my $uncompressed = ''; + my $chunk; + my $status; + while (($status = $z->read($chunk, 65536)) > 0) { + $uncompressed .= $chunk; + _check_decompress_length(length($uncompressed), $limit); + } + if (!defined $status || $status < 0) { + croak "Error decompressing block"; + } + return $uncompressed; +} + +sub _check_decompress_length { + my ($length, $limit) = @_; + if ($length > $limit) { + Avro::DataFile::Error::DecompressionSize->throw( + "Decompressed block size 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; @@ -308,4 +362,7 @@ sub eof { package Avro::DataFile::Error::UnsupportedCodec; use parent 'Error::Simple'; +package Avro::DataFile::Error::DecompressionSize; +use parent -norequire, 'Error::Simple'; + 1; diff --git a/lang/perl/t/07_datafile_decompress_limit.t b/lang/perl/t/07_datafile_decompress_limit.t new file mode 100644 index 00000000000..c7f6c87701d --- /dev/null +++ b/lang/perl/t/07_datafile_decompress_limit.t @@ -0,0 +1,93 @@ +# 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. + +#!/usr/bin/env perl + +# A data-file block is decompressed according to the file's codec. A block with +# a very high compression ratio can expand to far more memory than its +# compressed size. These tests ensure that reading such a block is rejected +# instead of allocating without bound. + +use strict; +use warnings; +use File::Temp; +use Avro::Schema; +use Avro::DataFileWriter; +use Test::More; +use Test::Exception; + +use_ok 'Avro::DataFileReader'; + +my $schema = Avro::Schema->parse('"string"'); + +sub deflate_file { + my ($payload) = @_; + my $fh = File::Temp->new(UNLINK => 1); + my $writer = Avro::DataFileWriter->new( + fh => $fh, + writer_schema => $schema, + codec => 'deflate', + ); + $writer->print($payload); + $writer->flush; + seek $fh, 0, 0; + return $fh; +} + +## A large, highly compressible value compresses to a tiny block but would +## decompress to far more than the configured limit. +{ + my $big = "a" x (64 * 1024); # 64 KiB + my $fh = deflate_file($big); + my $reader = Avro::DataFileReader->new( + fh => $fh, + reader_schema => $schema, + block_max_size => 1024, + ); + throws_ok { $reader->all } + 'Avro::DataFile::Error::DecompressionSize', + 'deflate block exceeding the limit is rejected'; +} + +## The AVRO_MAX_DECOMPRESS_LENGTH environment variable is honored too. +{ + my $big = "a" x (64 * 1024); + my $fh = deflate_file($big); + local $ENV{AVRO_MAX_DECOMPRESS_LENGTH} = 1024; + my $reader = Avro::DataFileReader->new( + fh => $fh, + reader_schema => $schema, + ); + throws_ok { $reader->all } + 'Avro::DataFile::Error::DecompressionSize', + 'AVRO_MAX_DECOMPRESS_LENGTH is honored'; +} + +## A block within the limit still decodes correctly. +{ + my $payload = "hello world"; + my $fh = deflate_file($payload); + my $reader = Avro::DataFileReader->new( + fh => $fh, + reader_schema => $schema, + block_max_size => 1024 * 1024, + ); + my @all = $reader->all; + is_deeply \@all, [$payload], 'deflate block within the limit decodes'; +} + +done_testing; From aa239b8c805b52889345415f22cef79dac7327d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isma=C3=ABl=20Mej=C3=ADa?= Date: Sat, 11 Jul 2026 17:35:01 +0200 Subject: [PATCH 02/16] AVRO-4288: [perl] Address review: decouple block_max_size; bound zstd; tests - Stop reusing block_max_size (a writer-side compressed-block flush threshold) for the reader's decompressed-size cap; the limit is now the AVRO_MAX_DECOMPRESS_LENGTH environment variable / DEFAULT_MAX_DECOMPRESS_LENGTH. - Decompress zstandard via Compress::Zstd::Decompressor in chunks so an over-large block is rejected before its full form is materialized. - Tighten _inflate_bounded: size each read to the remaining budget (capped at 64 KiB) so the buffer overshoots the limit by at most one byte, and include the decompressor error in the croak message. - Make the DecompressionSize error class consistent (use parent 'Error::Simple'). - Add over-limit tests for bzip2 and zstandard (skipped when unavailable). Assisted-by: GitHub Copilot:claude-opus-4.8 --- lang/perl/lib/Avro/DataFileReader.pm | 51 +++++++++++++++------ lang/perl/t/07_datafile_decompress_limit.t | 52 +++++++++++----------- 2 files changed, 65 insertions(+), 38 deletions(-) diff --git a/lang/perl/lib/Avro/DataFileReader.pm b/lang/perl/lib/Avro/DataFileReader.pm index b480dd196a9..96e6fcf8fcf 100644 --- a/lang/perl/lib/Avro/DataFileReader.pm +++ b/lang/perl/lib/Avro/DataFileReader.pm @@ -32,16 +32,15 @@ use constant MARKER_SIZE => 16; # 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 per reader via -# the block_max_size attribute, or globally with the AVRO_MAX_DECOMPRESS_LENGTH -# environment variable. +# 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 Compress::Zstd::Decompressor; use IO::Uncompress::Bunzip2 qw(bunzip2); use IO::Uncompress::RawInflate ; use Fcntl(); @@ -223,9 +222,11 @@ sub read_block_header { ## 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. - my $limit = $datafile->block_max_size; - $limit = _max_decompress_length() unless defined $limit; + ## 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 and is not reused + ## here to avoid conflating the two units.) + my $limit = _max_decompress_length(); ## this is our new reader $datafile->{reader} = do { @@ -242,8 +243,7 @@ sub read_block_header { do { open my $fh, '<', \$uncompressed; $fh }; } elsif ($codec eq 'zstandard') { - my $uncompressed = decompress(\$block); - _check_decompress_length(length($uncompressed), $limit); + my $uncompressed = _zstd_decompress_bounded(\$block, $limit); do { open my $fh, '<', \$uncompressed; $fh }; } }; @@ -253,18 +253,43 @@ sub read_block_header { ## 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. +## 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 (($status = $z->read($chunk, 65536)) > 0) { + while (1) { + my $budget = $limit - 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(length($uncompressed), $limit); } if (!defined $status || $status < 0) { - croak "Error decompressing block"; + 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) = @_; + my $decompressor = Compress::Zstd::Decompressor->new; + my $uncompressed = ''; + my $length = length($$block_ref); + my $offset = 0; + while ($offset < $length) { + my $piece = substr($$block_ref, $offset, 65536); + $offset += 65536; + my $out = $decompressor->decompress($piece); + next unless defined $out; + $uncompressed .= $out; + _check_decompress_length(length($uncompressed), $limit); } return $uncompressed; } @@ -363,6 +388,6 @@ package Avro::DataFile::Error::UnsupportedCodec; use parent 'Error::Simple'; package Avro::DataFile::Error::DecompressionSize; -use parent -norequire, 'Error::Simple'; +use parent 'Error::Simple'; 1; diff --git a/lang/perl/t/07_datafile_decompress_limit.t b/lang/perl/t/07_datafile_decompress_limit.t index c7f6c87701d..701da59825c 100644 --- a/lang/perl/t/07_datafile_decompress_limit.t +++ b/lang/perl/t/07_datafile_decompress_limit.t @@ -34,13 +34,13 @@ use_ok 'Avro::DataFileReader'; my $schema = Avro::Schema->parse('"string"'); -sub deflate_file { - my ($payload) = @_; +sub codec_file { + my ($codec, $payload) = @_; my $fh = File::Temp->new(UNLINK => 1); my $writer = Avro::DataFileWriter->new( fh => $fh, writer_schema => $schema, - codec => 'deflate', + codec => $codec, ); $writer->print($payload); $writer->flush; @@ -49,24 +49,12 @@ sub deflate_file { } ## A large, highly compressible value compresses to a tiny block but would -## decompress to far more than the configured limit. -{ +## decompress to far more than the configured limit; reading it must be rejected +## for every codec. The limit is set via AVRO_MAX_DECOMPRESS_LENGTH. +sub assert_codec_rejects_oversized { + my ($codec) = @_; my $big = "a" x (64 * 1024); # 64 KiB - my $fh = deflate_file($big); - my $reader = Avro::DataFileReader->new( - fh => $fh, - reader_schema => $schema, - block_max_size => 1024, - ); - throws_ok { $reader->all } - 'Avro::DataFile::Error::DecompressionSize', - 'deflate block exceeding the limit is rejected'; -} - -## The AVRO_MAX_DECOMPRESS_LENGTH environment variable is honored too. -{ - my $big = "a" x (64 * 1024); - my $fh = deflate_file($big); + my $fh = codec_file($codec, $big); local $ENV{AVRO_MAX_DECOMPRESS_LENGTH} = 1024; my $reader = Avro::DataFileReader->new( fh => $fh, @@ -74,17 +62,31 @@ sub deflate_file { ); throws_ok { $reader->all } 'Avro::DataFile::Error::DecompressionSize', - 'AVRO_MAX_DECOMPRESS_LENGTH is honored'; + "$codec block exceeding the limit is rejected"; +} + +assert_codec_rejects_oversized('deflate'); + +SKIP: { + eval { require IO::Compress::Bzip2; 1 } + or skip 'IO::Compress::Bzip2 not available', 1; + assert_codec_rejects_oversized('bzip2'); +} + +SKIP: { + eval { require Compress::Zstd; 1 } + or skip 'Compress::Zstd not available', 1; + assert_codec_rejects_oversized('zstandard'); } ## A block within the limit still decodes correctly. { my $payload = "hello world"; - my $fh = deflate_file($payload); + my $fh = codec_file('deflate', $payload); + local $ENV{AVRO_MAX_DECOMPRESS_LENGTH} = 1024 * 1024; my $reader = Avro::DataFileReader->new( - fh => $fh, - reader_schema => $schema, - block_max_size => 1024 * 1024, + fh => $fh, + reader_schema => $schema, ); my @all = $reader->all; is_deeply \@all, [$payload], 'deflate block within the limit decodes'; From 985cabcbfb201f4e4a186948f27f803c1fe3e2f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isma=C3=ABl=20Mej=C3=ADa?= Date: Sat, 11 Jul 2026 18:59:09 +0200 Subject: [PATCH 03/16] AVRO-4288: [perl] Drop unused bunzip2 import Only the OO IO::Uncompress::Bunzip2->new interface is used; import the module without the bunzip2 function to avoid an unused import. Assisted-by: GitHub Copilot:claude-opus-4.8 --- lang/perl/lib/Avro/DataFileReader.pm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lang/perl/lib/Avro/DataFileReader.pm b/lang/perl/lib/Avro/DataFileReader.pm index 96e6fcf8fcf..d7d9ea9b53b 100644 --- a/lang/perl/lib/Avro/DataFileReader.pm +++ b/lang/perl/lib/Avro/DataFileReader.pm @@ -41,7 +41,7 @@ use Avro::BinaryDecoder; use Avro::Schema; use Carp; use Compress::Zstd::Decompressor; -use IO::Uncompress::Bunzip2 qw(bunzip2); +use IO::Uncompress::Bunzip2 (); use IO::Uncompress::RawInflate ; use Fcntl(); From d924bc29bd8fe34b958ea8a4c5d2492e3b3e8e5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isma=C3=ABl=20Mej=C3=ADa?= Date: Sat, 11 Jul 2026 19:36:01 +0200 Subject: [PATCH 04/16] AVRO-4288: [perl] Fail closed on zstd decompress failure; test bzip2/zstd round-trips - _zstd_decompress_bounded now treats an undefined decompress() return as a failure and croaks, instead of silently skipping it, so a malformed block cannot masquerade as a short, within-limit result. (The streaming decompressor emits all output while consuming its input and has no separate flush step, noted in a comment.) - The within-limit decode test is now a helper exercised for bzip2 and zstandard as well as deflate (skipped when the codec modules are unavailable), guarding the bounded streaming paths against regressions. Assisted-by: GitHub Copilot:claude-opus-4.8 --- lang/perl/lib/Avro/DataFileReader.pm | 9 ++++++++- lang/perl/t/07_datafile_decompress_limit.t | 21 ++++++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/lang/perl/lib/Avro/DataFileReader.pm b/lang/perl/lib/Avro/DataFileReader.pm index d7d9ea9b53b..1a57615a720 100644 --- a/lang/perl/lib/Avro/DataFileReader.pm +++ b/lang/perl/lib/Avro/DataFileReader.pm @@ -287,7 +287,14 @@ sub _zstd_decompress_bounded { my $piece = substr($$block_ref, $offset, 65536); $offset += 65536; my $out = $decompressor->decompress($piece); - next unless defined $out; + # 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"; + } $uncompressed .= $out; _check_decompress_length(length($uncompressed), $limit); } diff --git a/lang/perl/t/07_datafile_decompress_limit.t b/lang/perl/t/07_datafile_decompress_limit.t index 701da59825c..aa511da9600 100644 --- a/lang/perl/t/07_datafile_decompress_limit.t +++ b/lang/perl/t/07_datafile_decompress_limit.t @@ -80,16 +80,31 @@ SKIP: { } ## A block within the limit still decodes correctly. -{ +sub assert_codec_within_limit_decodes { + my ($codec) = @_; my $payload = "hello world"; - my $fh = codec_file('deflate', $payload); + my $fh = codec_file($codec, $payload); local $ENV{AVRO_MAX_DECOMPRESS_LENGTH} = 1024 * 1024; my $reader = Avro::DataFileReader->new( fh => $fh, reader_schema => $schema, ); my @all = $reader->all; - is_deeply \@all, [$payload], 'deflate block within the limit decodes'; + is_deeply \@all, [$payload], "$codec block within the limit decodes"; +} + +assert_codec_within_limit_decodes('deflate'); + +SKIP: { + eval { require IO::Compress::Bzip2; 1 } + or skip 'IO::Compress::Bzip2 not available', 1; + assert_codec_within_limit_decodes('bzip2'); +} + +SKIP: { + eval { require Compress::Zstd; 1 } + or skip 'Compress::Zstd not available', 1; + assert_codec_within_limit_decodes('zstandard'); } done_testing; From f3d866e3e56c7365ab0f39a6cd9ad3f42a2d68c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isma=C3=ABl=20Mej=C3=ADa?= Date: Sat, 11 Jul 2026 19:58:20 +0200 Subject: [PATCH 05/16] AVRO-4288: [perl] Enforce the decompress limit on bytes, not characters _inflate_bounded and _zstd_decompress_bounded counted the running output with length(), which returns character length. If the scalar were ever UTF-8 upgraded, that could undercount bytes and let a block exceed the configured byte cap. Use bytes::length() for byte-accurate accounting, matching how Avro::DataFileWriter tracks block sizes. Assisted-by: GitHub Copilot:claude-opus-4.8 --- lang/perl/lib/Avro/DataFileReader.pm | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lang/perl/lib/Avro/DataFileReader.pm b/lang/perl/lib/Avro/DataFileReader.pm index 1a57615a720..2acf1ce2bb4 100644 --- a/lang/perl/lib/Avro/DataFileReader.pm +++ b/lang/perl/lib/Avro/DataFileReader.pm @@ -44,6 +44,7 @@ use Compress::Zstd::Decompressor; use IO::Uncompress::Bunzip2 (); use IO::Uncompress::RawInflate ; use Fcntl(); +use bytes (); our $VERSION = '++MODULE_VERSION++'; @@ -262,12 +263,12 @@ sub _inflate_bounded { my $chunk; my $status; while (1) { - my $budget = $limit - length($uncompressed) + 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(length($uncompressed), $limit); + _check_decompress_length(bytes::length($uncompressed), $limit); } if (!defined $status || $status < 0) { croak "Error decompressing block: " . $z->error; @@ -281,7 +282,7 @@ sub _zstd_decompress_bounded { my ($block_ref, $limit) = @_; my $decompressor = Compress::Zstd::Decompressor->new; my $uncompressed = ''; - my $length = length($$block_ref); + my $length = bytes::length($$block_ref); my $offset = 0; while ($offset < $length) { my $piece = substr($$block_ref, $offset, 65536); @@ -296,7 +297,7 @@ sub _zstd_decompress_bounded { croak "Error decompressing zstandard block"; } $uncompressed .= $out; - _check_decompress_length(length($uncompressed), $limit); + _check_decompress_length(bytes::length($uncompressed), $limit); } return $uncompressed; } From cbba52aa018196cd90d36f5a1ef95314840c4e6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isma=C3=ABl=20Mej=C3=ADa?= Date: Sat, 11 Jul 2026 20:19:11 +0200 Subject: [PATCH 06/16] AVRO-4288: [perl] Bound compressed block read; check in-memory open - Guard against an attacker-controlled block_size causing a huge allocation for the compressed block itself: when the reader is configured with block_max_size, reject a block whose declared compressed size exceeds it before reading the block into memory. Added a test. - The in-memory read handles over the decompressed block were opened without checking for failure, leaving reader undef and surfacing unclear errors later. Factor the three open sites into _open_decompressed(), which croaks with $! on failure. Assisted-by: GitHub Copilot:claude-opus-4.8 --- lang/perl/lib/Avro/DataFileReader.pm | 28 +++++++++++++++++++--- lang/perl/t/07_datafile_decompress_limit.t | 16 +++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/lang/perl/lib/Avro/DataFileReader.pm b/lang/perl/lib/Avro/DataFileReader.pm index 2acf1ce2bb4..287d15b20d4 100644 --- a/lang/perl/lib/Avro/DataFileReader.pm +++ b/lang/perl/lib/Avro/DataFileReader.pm @@ -213,6 +213,17 @@ sub read_block_header { return if $codec eq 'null'; + ## 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}; + if (defined $block_max && $datafile->{block_size} > $block_max) { + Avro::DataFile::Error::DecompressionSize->throw( + "Compressed block size $datafile->{block_size} exceeds the configured block_max_size of $block_max bytes" + ); + } + ## 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: $!"; @@ -235,23 +246,34 @@ sub read_block_header { my $z = IO::Uncompress::RawInflate->new(\$block) or croak "Error inflating block: $IO::Uncompress::RawInflate::RawInflateError"; my $uncompressed = _inflate_bounded($z, $limit); - do { open my $fh, '<', \$uncompressed; $fh }; + _open_decompressed(\$uncompressed); } elsif ($codec eq 'bzip2') { my $z = IO::Uncompress::Bunzip2->new(\$block) or croak "Error decompressing bzip2 block: $IO::Uncompress::Bunzip2::Bunzip2Error"; my $uncompressed = _inflate_bounded($z, $limit); - do { open my $fh, '<', \$uncompressed; $fh }; + _open_decompressed(\$uncompressed); } elsif ($codec eq 'zstandard') { my $uncompressed = _zstd_decompress_bounded(\$block, $limit); - do { open my $fh, '<', \$uncompressed; $fh }; + _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 diff --git a/lang/perl/t/07_datafile_decompress_limit.t b/lang/perl/t/07_datafile_decompress_limit.t index aa511da9600..36b0e679d68 100644 --- a/lang/perl/t/07_datafile_decompress_limit.t +++ b/lang/perl/t/07_datafile_decompress_limit.t @@ -107,4 +107,20 @@ SKIP: { assert_codec_within_limit_decodes('zstandard'); } +## When block_max_size is configured on the reader, a block whose declared +## compressed size exceeds it is rejected before the compressed block is read +## into memory (guarding against an attacker-controlled block_size allocation). +{ + my $payload = "a" x (32 * 1024); # 32 KiB, compresses to a few dozen bytes + my $fh = codec_file('deflate', $payload); + my $reader = Avro::DataFileReader->new( + fh => $fh, + reader_schema => $schema, + block_max_size => 8, # smaller than any real compressed block + ); + throws_ok { $reader->all } + 'Avro::DataFile::Error::DecompressionSize', + 'compressed block exceeding block_max_size is rejected before reading'; +} + done_testing; From ccab12c5f7b2a29a1986faf55ad4e530e0f30ee3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isma=C3=ABl=20Mej=C3=ADa?= Date: Sat, 11 Jul 2026 20:32:50 +0200 Subject: [PATCH 07/16] AVRO-4288: [perl] Skip zstd tests on the exact module the reader needs The zstandard SKIP guards checked for Compress::Zstd, but the reader depends on Compress::Zstd::Decompressor. Require that submodule in the SKIP conditions so the tests are reliably skipped when the reader-side dependency is missing rather than attempting to run and failing later. Assisted-by: GitHub Copilot:claude-opus-4.8 --- lang/perl/t/07_datafile_decompress_limit.t | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lang/perl/t/07_datafile_decompress_limit.t b/lang/perl/t/07_datafile_decompress_limit.t index 36b0e679d68..95260f326cb 100644 --- a/lang/perl/t/07_datafile_decompress_limit.t +++ b/lang/perl/t/07_datafile_decompress_limit.t @@ -74,8 +74,8 @@ SKIP: { } SKIP: { - eval { require Compress::Zstd; 1 } - or skip 'Compress::Zstd not available', 1; + eval { require Compress::Zstd::Decompressor; 1 } + or skip 'Compress::Zstd::Decompressor not available', 1; assert_codec_rejects_oversized('zstandard'); } @@ -102,8 +102,8 @@ SKIP: { } SKIP: { - eval { require Compress::Zstd; 1 } - or skip 'Compress::Zstd not available', 1; + eval { require Compress::Zstd::Decompressor; 1 } + or skip 'Compress::Zstd::Decompressor not available', 1; assert_codec_within_limit_decodes('zstandard'); } From 5b1ba0315c2bf905b10576e390ed9d0b9fba24ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isma=C3=ABl=20Mej=C3=ADa?= Date: Sat, 11 Jul 2026 20:46:30 +0200 Subject: [PATCH 08/16] AVRO-4288: [perl] Lazy-load zstandard decompressor The reader hard-required Compress::Zstd::Decompressor at compile time, so an environment with an older Compress::Zstd lacking the Decompressor submodule would fail to load the reader entirely, even when only other codecs are used. Load Compress::Zstd::Decompressor lazily in the zstandard path and throw Avro::DataFile::Error::UnsupportedCodec with a clear message when it is unavailable, so the reader keeps working for the other codecs. Assisted-by: GitHub Copilot:claude-opus-4.8 --- lang/perl/lib/Avro/DataFileReader.pm | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lang/perl/lib/Avro/DataFileReader.pm b/lang/perl/lib/Avro/DataFileReader.pm index 287d15b20d4..97506c393ce 100644 --- a/lang/perl/lib/Avro/DataFileReader.pm +++ b/lang/perl/lib/Avro/DataFileReader.pm @@ -40,7 +40,6 @@ use Avro::DataFile; use Avro::BinaryDecoder; use Avro::Schema; use Carp; -use Compress::Zstd::Decompressor; use IO::Uncompress::Bunzip2 (); use IO::Uncompress::RawInflate ; use Fcntl(); @@ -302,6 +301,14 @@ sub _inflate_bounded { ## 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). + unless (eval { require Compress::Zstd::Decompressor; 1 }) { + Avro::DataFile::Error::UnsupportedCodec->throw( + "Cannot read zstandard-compressed block: Compress::Zstd::Decompressor is not available" + ); + } my $decompressor = Compress::Zstd::Decompressor->new; my $uncompressed = ''; my $length = bytes::length($$block_ref); From f91798860d5bbca848fc06c22f8ab0aeee1c6f15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isma=C3=ABl=20Mej=C3=ADa?= Date: Sat, 11 Jul 2026 21:06:08 +0200 Subject: [PATCH 09/16] AVRO-4288: [perl] Check zstd size before concat; distinct compressed-size error - _zstd_decompress_bounded now checks the prospective total length before concatenating each decompressed chunk into the buffer, so a single large chunk cannot transiently balloon memory past the limit (or double peak memory via reallocation). - The block_max_size pre-read guard now throws a distinct Avro::DataFile::Error::CompressedBlockSize (compressed-size condition) instead of DecompressionSize (decompressed-size cap), so callers can tell the two apart. - The block_max_size test sets a large AVRO_MAX_DECOMPRESS_LENGTH so the rejection can only come from the compressed-size guard, and asserts the distinct class and message. Assisted-by: GitHub Copilot:claude-opus-4.8 --- lang/perl/lib/Avro/DataFileReader.pm | 15 +++++++++++++-- lang/perl/t/07_datafile_decompress_limit.t | 11 +++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/lang/perl/lib/Avro/DataFileReader.pm b/lang/perl/lib/Avro/DataFileReader.pm index 97506c393ce..412109ed6df 100644 --- a/lang/perl/lib/Avro/DataFileReader.pm +++ b/lang/perl/lib/Avro/DataFileReader.pm @@ -218,7 +218,7 @@ sub read_block_header { ## block whose declared compressed size exceeds that bound up front. my $block_max = $datafile->{block_max_size}; if (defined $block_max && $datafile->{block_size} > $block_max) { - Avro::DataFile::Error::DecompressionSize->throw( + Avro::DataFile::Error::CompressedBlockSize->throw( "Compressed block size $datafile->{block_size} exceeds the configured block_max_size of $block_max bytes" ); } @@ -325,8 +325,12 @@ sub _zstd_decompress_bounded { 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; - _check_decompress_length(bytes::length($uncompressed), $limit); } return $uncompressed; } @@ -427,4 +431,11 @@ 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; diff --git a/lang/perl/t/07_datafile_decompress_limit.t b/lang/perl/t/07_datafile_decompress_limit.t index 95260f326cb..6d11d8a8819 100644 --- a/lang/perl/t/07_datafile_decompress_limit.t +++ b/lang/perl/t/07_datafile_decompress_limit.t @@ -113,14 +113,21 @@ SKIP: { { my $payload = "a" x (32 * 1024); # 32 KiB, compresses to a few dozen bytes my $fh = codec_file('deflate', $payload); + # Set a large decompressed-size cap so the rejection can only come from the + # compressed-size guard, not the decompression limit, regardless of any + # AVRO_MAX_DECOMPRESS_LENGTH the runner may have set. + local $ENV{AVRO_MAX_DECOMPRESS_LENGTH} = 1024 * 1024 * 1024; my $reader = Avro::DataFileReader->new( fh => $fh, reader_schema => $schema, block_max_size => 8, # smaller than any real compressed block ); - throws_ok { $reader->all } - 'Avro::DataFile::Error::DecompressionSize', + eval { $reader->all }; + my $err = $@; + isa_ok $err, 'Avro::DataFile::Error::CompressedBlockSize', 'compressed block exceeding block_max_size is rejected before reading'; + like "$err", qr/block_max_size/, + 'rejection is due to the compressed-size guard, not the decompression cap'; } done_testing; From 31f054c0d189080c1cb93cf89f9ce18809359ecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isma=C3=ABl=20Mej=C3=ADa?= Date: Sat, 11 Jul 2026 21:24:06 +0200 Subject: [PATCH 10/16] AVRO-4288: [perl] Require Compress::Zstd 0.10; verify block read length - The reader needs Compress::Zstd::Decompressor (the streaming interface first shipped in Compress::Zstd 0.10), so bump the prerequisite to require >= 0.10 and skip the zstandard case in t/04_datafile.t when the Decompressor submodule is unavailable, keeping the suite portable. - The block read used 'or croak', which treats a short read / EOF (with $! often unset) as a generic error and lets a partial read slip through. Verify the exact expected byte count and croak with a clear 'short read' message on a truncated/malformed file. Assisted-by: GitHub Copilot:claude-opus-4.8 --- lang/perl/Makefile.PL | 2 +- lang/perl/lib/Avro/DataFileReader.pm | 15 +++++++-- lang/perl/t/04_datafile.t | 49 +++++++++++++++------------- 3 files changed, 40 insertions(+), 26 deletions(-) diff --git a/lang/perl/Makefile.PL b/lang/perl/Makefile.PL index 4c07baac00e..b3b13042432 100644 --- a/lang/perl/Makefile.PL +++ b/lang/perl/Makefile.PL @@ -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'; diff --git a/lang/perl/lib/Avro/DataFileReader.pm b/lang/perl/lib/Avro/DataFileReader.pm index 412109ed6df..161f22a019e 100644 --- a/lang/perl/lib/Avro/DataFileReader.pm +++ b/lang/perl/lib/Avro/DataFileReader.pm @@ -223,9 +223,18 @@ sub read_block_header { ); } - ## 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: $!"; + ## we need to read the entire block into memory, to inflate it. Verify the + ## exact byte count: 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 $nread = read $fh, $block, $want; + if (!defined $nread) { + croak "Error reading from file: $!"; + } + if ($nread != $want) { + croak "Short read: expected $want bytes for the block, got $nread (truncated file?)"; + } ## remove the marker my $marker = substr $block, -(MARKER_SIZE), MARKER_SIZE, ''; diff --git a/lang/perl/t/04_datafile.t b/lang/perl/t/04_datafile.t index df7861b191e..e03d2ff7f94 100644 --- a/lang/perl/t/04_datafile.t +++ b/lang/perl/t/04_datafile.t @@ -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 From dc8de8653a17953151be2ada7c4993ca39f5b27b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isma=C3=ABl=20Mej=C3=ADa?= Date: Mon, 13 Jul 2026 01:32:39 +0200 Subject: [PATCH 11/16] AVRO-4288: [perl] Include underlying error and size in decompression messages Address review feedback: - Append $@ to the zstandard "not available" error so a require failure for a reason other than a missing module (missing shared library, compile error) is diagnosable. - Include the actual decompressed $length in the DecompressionSize exception message so the failure reports how large the block was relative to the limit. Assisted-by: GitHub Copilot:claude-opus-4.8 --- lang/perl/lib/Avro/DataFileReader.pm | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lang/perl/lib/Avro/DataFileReader.pm b/lang/perl/lib/Avro/DataFileReader.pm index 161f22a019e..62963f163a9 100644 --- a/lang/perl/lib/Avro/DataFileReader.pm +++ b/lang/perl/lib/Avro/DataFileReader.pm @@ -315,7 +315,7 @@ sub _zstd_decompress_bounded { # older Compress::Zstd distribution that lacks the Decompressor submodule). unless (eval { require Compress::Zstd::Decompressor; 1 }) { Avro::DataFile::Error::UnsupportedCodec->throw( - "Cannot read zstandard-compressed block: Compress::Zstd::Decompressor is not available" + "Cannot read zstandard-compressed block: Compress::Zstd::Decompressor is not available: $@" ); } my $decompressor = Compress::Zstd::Decompressor->new; @@ -348,7 +348,7 @@ sub _check_decompress_length { my ($length, $limit) = @_; if ($length > $limit) { Avro::DataFile::Error::DecompressionSize->throw( - "Decompressed block size exceeds the maximum allowed of $limit bytes" + "Decompressed block size $length exceeds the maximum allowed of $limit bytes" ); } return; From aa45cf034be8aa79031ce40b9383b5564b938eb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isma=C3=ABl=20Mej=C3=ADa?= Date: Mon, 13 Jul 2026 01:51:05 +0200 Subject: [PATCH 12/16] AVRO-4288: [perl] Extract block_size local in the compressed-size message Perl does interpolate $datafile->{block_size} in a double-quoted string, so the message was already correct, but extract a $block_size local and use it in both the comparison and the message for clarity. Assisted-by: GitHub Copilot:claude-opus-4.8 --- lang/perl/lib/Avro/DataFileReader.pm | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lang/perl/lib/Avro/DataFileReader.pm b/lang/perl/lib/Avro/DataFileReader.pm index 62963f163a9..68e1c046673 100644 --- a/lang/perl/lib/Avro/DataFileReader.pm +++ b/lang/perl/lib/Avro/DataFileReader.pm @@ -217,9 +217,10 @@ sub read_block_header { ## 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}; - if (defined $block_max && $datafile->{block_size} > $block_max) { + my $block_size = $datafile->{block_size}; + if (defined $block_max && $block_size > $block_max) { Avro::DataFile::Error::CompressedBlockSize->throw( - "Compressed block size $datafile->{block_size} exceeds the configured block_max_size of $block_max bytes" + "Compressed block size $block_size exceeds the configured block_max_size of $block_max bytes" ); } From dd7c9e83265712cebb8a5effe68fdbc646b0ae1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isma=C3=ABl=20Mej=C3=ADa?= Date: Mon, 13 Jul 2026 02:28:23 +0200 Subject: [PATCH 13/16] AVRO-4288: [perl] Reject negative object_count/block_size; clarify comment Address review feedback: - object_count and block_size are decoded Avro longs, so a malformed/truncated file can yield negatives. A negative block_size flows into $want and a negative-length read; reject both immediately after decoding. - Clarify the decompression-limit comment: block_max_size IS now reused above as a compressed-size pre-read guard, but not here for the decompressed-size cap. Assisted-by: GitHub Copilot:claude-opus-4.8 --- lang/perl/lib/Avro/DataFileReader.pm | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/lang/perl/lib/Avro/DataFileReader.pm b/lang/perl/lib/Avro/DataFileReader.pm index 68e1c046673..eff9652afcb 100644 --- a/lang/perl/lib/Avro/DataFileReader.pm +++ b/lang/perl/lib/Avro/DataFileReader.pm @@ -208,6 +208,16 @@ sub read_block_header { $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'; @@ -245,8 +255,9 @@ sub read_block_header { ## 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 and is not reused - ## here to avoid conflating the two units.) + ## 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(); ## this is our new reader From 55827e07cb7e120090c2bff64a8ac48a4cb5b070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isma=C3=ABl=20Mej=C3=ADa?= Date: Mon, 13 Jul 2026 03:36:04 +0200 Subject: [PATCH 14/16] AVRO-4288: [perl] Read data-file block in bounded chunks read($fh, $block, $want) pre-extends the buffer to the attacker-controlled $want (block_size + marker) before a short read is detected, so a malformed file declaring an enormous block_size but carrying little data forces a huge allocation. Read in bounded 64 KiB chunks until $want or EOF, then verify the exact byte count, so a truncated file fails after a bounded allocation. Assisted-by: GitHub Copilot:claude-opus-4.8 --- lang/perl/lib/Avro/DataFileReader.pm | 29 +++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/lang/perl/lib/Avro/DataFileReader.pm b/lang/perl/lib/Avro/DataFileReader.pm index eff9652afcb..819d968109e 100644 --- a/lang/perl/lib/Avro/DataFileReader.pm +++ b/lang/perl/lib/Avro/DataFileReader.pm @@ -234,17 +234,28 @@ sub read_block_header { ); } - ## we need to read the entire block into memory, to inflate it. Verify the - ## exact byte count: a short read (truncated/malformed file) would otherwise - ## slip through and surface later as a confusing marker/decompressor error. + ## 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 $nread = read $fh, $block, $want; - if (!defined $nread) { - croak "Error reading from file: $!"; + my $block = ''; + my $chunk_size = 64 * 1024; + while (length($block) < $want) { + my $need = $want - length($block); + $need = $chunk_size if $need > $chunk_size; + my $nread = read $fh, my $buf, $need; + if (!defined $nread) { + croak "Error reading from file: $!"; + } + last if $nread == 0; # EOF + $block .= $buf; } - if ($nread != $want) { - croak "Short read: expected $want bytes for the block, got $nread (truncated file?)"; + if (length($block) != $want) { + croak "Short read: expected $want bytes for the block, got " + . length($block) . " (truncated file?)"; } ## remove the marker From 308cceaaa4c8dd2684b2bd7a36c25d6da64af073 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isma=C3=ABl=20Mej=C3=ADa?= Date: Mon, 13 Jul 2026 06:41:18 +0200 Subject: [PATCH 15/16] AVRO-4288: [perl] Byte-count the block read; bound zstd per-call output - The bounded block-read loop and short-read check now use bytes::length instead of length, so a UTF-8-flagged scalar (e.g. from an IO layer) cannot miscount the accumulated bytes and mis-handle the marker boundary. - Feed the zstd streaming decompressor 16 KiB input pieces instead of 64 KiB. Compress::Zstd's decompress() has no max-output parameter, so a single call materializes all output from the input it is handed; smaller pieces bound the transient $out (roughly one internal block) so a highly-compressible frame cannot balloon one call's output before the size check runs. Assisted-by: GitHub Copilot:claude-opus-4.8 --- lang/perl/lib/Avro/DataFileReader.pm | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/lang/perl/lib/Avro/DataFileReader.pm b/lang/perl/lib/Avro/DataFileReader.pm index 819d968109e..5d7d90f228c 100644 --- a/lang/perl/lib/Avro/DataFileReader.pm +++ b/lang/perl/lib/Avro/DataFileReader.pm @@ -243,8 +243,8 @@ sub read_block_header { my $want = $datafile->{block_size} + MARKER_SIZE; my $block = ''; my $chunk_size = 64 * 1024; - while (length($block) < $want) { - my $need = $want - length($block); + 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; if (!defined $nread) { @@ -253,9 +253,9 @@ sub read_block_header { last if $nread == 0; # EOF $block .= $buf; } - if (length($block) != $want) { + if (bytes::length($block) != $want) { croak "Short read: expected $want bytes for the block, got " - . length($block) . " (truncated file?)"; + . bytes::length($block) . " (truncated file?)"; } ## remove the marker @@ -345,9 +345,16 @@ sub _zstd_decompress_bounded { 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, 65536); - $offset += 65536; + 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 From 7f0e2e06f29de760df632248be5734234f8abdd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isma=C3=ABl=20Mej=C3=ADa?= Date: Mon, 13 Jul 2026 07:05:39 +0200 Subject: [PATCH 16/16] AVRO-4288: [perl] Localize $@ when probing for the zstd decompressor The lazy `require Compress::Zstd::Decompressor` ran inside eval without localizing $@, so a successful probe would clear (or a failed one overwrite) the caller's $@. Localize $@ around the eval and capture the require error into a lexical before building the UnsupportedCodec message. Assisted-by: GitHub Copilot:claude-opus-4.8 --- lang/perl/lib/Avro/DataFileReader.pm | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lang/perl/lib/Avro/DataFileReader.pm b/lang/perl/lib/Avro/DataFileReader.pm index 5d7d90f228c..9d799d73caf 100644 --- a/lang/perl/lib/Avro/DataFileReader.pm +++ b/lang/perl/lib/Avro/DataFileReader.pm @@ -336,9 +336,12 @@ sub _zstd_decompress_bounded { # 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). - unless (eval { require Compress::Zstd::Decompressor; 1 }) { + # 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: $@" + "Cannot read zstandard-compressed block: Compress::Zstd::Decompressor is not available: $require_error" ); } my $decompressor = Compress::Zstd::Decompressor->new;