Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions Changes
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
Revision history for HTTP-Message

{{$NEXT}}
- SECURITY: CVE-2026-65060. decoded_content() did not enforce
max_body_size (or $HTTP::Message::MAXIMUM_BODY_SIZE) on the "deflate"
Content-Encoding, so a decompression bomb labelled "deflate" bypassed
the limit that gzip and bzip2 already honoured. The deflate and
raw-deflate paths are now bounded by the same limit. (GH#206)
(reported by grr)
- Reject a negative max_body_size instead of quietly decoding to nothing
- Document max_body_size() and $HTTP::Message::MAXIMUM_BODY_SIZE

7.03 2026-07-21 20:45:16Z
- Fix max_body_size for Content-Encoding: br, which made every brotli
Expand Down
105 changes: 97 additions & 8 deletions lib/HTTP/Message.pm
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,46 @@ sub _set_max_body_size {
$self->{_max_body_size} = $_[1];
}

# Uncompress $$input_ref with one of the IO::Uncompress::* classes, refusing
# to produce more than $limit octets of output. $limit may be undef, meaning
# no limit at all. Returns a reference to the decoded content, or undef if
# $class could not handle the input. Croaks if the content would decode to
# more than $limit octets; that is what makes max_body_size a defence against
# decompression bombs on these encodings.
sub _uncompress {
my($class, $input_ref, $limit, %options) = @_;

# Unlike the Compress::Raw::* adapters used for gzip and bzip2 above,
# these classes read their input without consuming it, so $input_ref can
# be handed over as is. That matters: the deflate branch below calls us a
# second time, with the same input, to retry as raw deflate.
my $z = $class->new($input_ref, Append => 1, %options) or return undef;

my $output = q{};
if (defined $limit) {
# Ask for one octet more than we are willing to keep: enough to tell
# content that fits from content that does not, and never more memory
# than the caller has already agreed to hand over.
my $read = $z->read($output, $limit + 1);
return undef if !defined $read || $read < 0;
Carp::croak("Decoded content would be larger than $limit octets")
if $read > $limit;
Comment on lines +314 to +317

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like the idea if reading an extra byte just to check if there is more data.

Would the following work instead?

my $read = $z->read( $output, $limit );
	return undef if !defined $read || $read < 0;
Carp::croak("Decoded content would be larger than $limit octets") unless $z->eof;

Question: would it make more sense to simple warn/carp?

}
else {
# Nothing to enforce, so read to the end of the stream a block at a
# time, which is what the one-shot IO::Uncompress functions do. Asking
# for a fixed number of octets instead takes a different path through
# IO::Uncompress::Base, and that path mis-reports the end of some raw
# deflate streams on perls older than 5.20.
while (1) {
my $read = $z->read($output);
return undef if !defined $read || $read < 0;
last if $read == 0;
}
}
return \$output;
}

sub decoded_content
{
my($self, %opt) = @_;
Expand All @@ -305,6 +345,12 @@ sub decoded_content
: defined $self->max_body_size ? $self->max_body_size
: undef
;
# A negative limit is a caller error, and every decoder reacts to one
# differently -- some quietly hand back no content at all, which looks
# just like a successful decode. Reject it up front instead.
die 'max_body_size must not be negative'
if defined $content_limit && $content_limit < 0;

my %limiter_options;
if( defined $content_limit ) {
%limiter_options = (LimitOutput => 1, Bufsize => $content_limit);
Expand Down Expand Up @@ -395,24 +441,27 @@ sub decoded_content
}
elsif ($ce eq "deflate") {
require IO::Uncompress::Inflate;
my $output;
my $status = IO::Uncompress::Inflate::inflate($content_ref, \$output, Transparent => 0);
my $output_ref = _uncompress(
'IO::Uncompress::Inflate', $content_ref, $content_limit,
Transparent => 0,
);
my $error = $IO::Uncompress::Inflate::InflateError;
unless ($status) {
unless ($output_ref) {
# "Content-Encoding: deflate" is supposed to mean the
# "zlib" format of RFC 1950, but Microsoft got that
# wrong, so some servers sends the raw compressed
# "deflate" data. This tries to inflate this format.
$output = undef;
require IO::Uncompress::RawInflate;
unless (IO::Uncompress::RawInflate::rawinflate($content_ref, \$output)) {
$output_ref = _uncompress(
'IO::Uncompress::RawInflate', $content_ref, $content_limit,
);
unless ($output_ref) {
$self->push_header("Client-Warning" =>
"Could not raw inflate content: $IO::Uncompress::RawInflate::RawInflateError");
$output = undef;
}
}
die "Can't inflate content: $error" unless defined $output;
$content_ref = \$output;
die "Can't inflate content: $error" unless $output_ref;
$content_ref = $output_ref;
$content_ref_iscopy++;
}
elsif ($ce eq "compress" || $ce eq "x-compress") {
Expand Down Expand Up @@ -1042,8 +1091,48 @@ If TRUE then a reference to decoded content is returned. This might
be more efficient in cases where the decoded content is identical to
the raw content as no data copying is required in this case.

=item C<max_body_size>

The maximum size, in octets, that the content is allowed to decode to.
This overrides the value set through max_body_size() for this call only.
See max_body_size() for details.

=back

=item $mess->max_body_size

=item $mess->max_body_size( $size )

Get/set the maximum size, in octets, that decoded_content() will allow the
content to decode to. A C<Content-Encoding> can compress hugely -- a few
kilobytes on the wire can inflate to gigabytes in memory -- so a hostile or
compromised server can exhaust your process's memory with a single response.
This limit is the defence against such a "decompression bomb": if the content
would decode to more than $size octets, decoded_content() aborts instead of
allocating it. Decoding works in blocks, so it can overshoot the limit by up
to one block before it notices and aborts. Leave some headroom. A negative
$size is an error.

The limit is enforced for the C<gzip>, C<x-gzip>, C<deflate>, C<br>, C<bzip2>
and C<x-bzip2> encodings. It is not applied to content that needs no decoding,
nor to the charset decoding step. The C<base64> and C<quoted-printable>
encodings are also exempt: neither can expand its input, so neither can be
used to mount this kind of attack.

Note that C<br> cannot distinguish content that exceeds the limit from content
that is simply corrupt, and reports both the same way.

The default is taken from C<$HTTP::Message::MAXIMUM_BODY_SIZE> when the
message is constructed. That variable is unset by default, meaning content is
decoded without any limit; set it if you want every message to be capped. Set
either to C<undef> to remove the limit again.

$HTTP::Message::MAXIMUM_BODY_SIZE = 100 * 1024 * 1024;

Since a response whose content exceeds the limit yields no content at all, it
is usually worth passing C<raise_error> to decoded_content() so you can tell
the two cases apart.

=item $mess->decodable

=item HTTP::Message::decodable()
Expand Down
180 changes: 180 additions & 0 deletions t/message-decode-deflatebomb.t
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
# CVE-2026-65060: max_body_size was not enforced on the "deflate" path,
# so a decompression bomb labelled Content-Encoding: deflate bypassed the
# cap that gzip/bzip2/brotli honour.

use strict;
use warnings;

use Test::More;

use HTTP::Headers qw( );
use HTTP::Response qw( );

use Test::Needs {
'IO::Compress::Deflate' => 0,
'IO::Compress::RawDeflate' => 0,
};

require IO::Compress::Deflate;
require IO::Compress::RawDeflate;

my $size = 1024 * 1024;
my $cap = 64 * 1024;
my $stream = "\0" x $size;

IO::Compress::Deflate::deflate( \$stream, \my $bomb, Level => 9 )
or die "Can't deflate content: $IO::Compress::Deflate::DeflateError";

IO::Compress::RawDeflate::rawdeflate( \$stream, \my $raw_bomb, Level => 9 )
or die
"Can't rawdeflate content: $IO::Compress::RawDeflate::RawDeflateError";

sub response_for {
my ( $body, $max_body_size ) = @_;
my $headers = HTTP::Headers->new(
Content_Type => 'application/octet-stream',
Content_Encoding => 'deflate',
);
my $response = HTTP::Response->new( 200, 'OK', $headers, $body );
$response->max_body_size($max_body_size);
return $response;
}

# Self-test: without a limit the bomb decodes in full.
{
my $content = response_for( $bomb, undef )->decoded_content;
is( length $content, $size,
'Self-test: zlib-deflate bomb decodes in full' );

$content = response_for( $raw_bomb, undef )->decoded_content;
is( length $content, $size,
'Self-test: raw-deflate bomb decodes in full' );
}

# The cap must be enforced, exactly as it is for gzip.
{
my $response = response_for( $bomb, $cap );
my $content;
my $lives = eval {
$content = $response->decoded_content( raise_error => 1 );
1;
};
is( $lives, undef,
'We die decoding a zlib-deflate body larger than max_body_size' );
like( $@, qr/larger than $cap octets/,
'... and the error names the limit' );
}

{
my $response = response_for( $raw_bomb, $cap );
my $content;
my $lives = eval {
$content = $response->decoded_content( raise_error => 1 );
1;
};
is( $lives, undef,
'We die decoding a raw-deflate body larger than max_body_size' );
like( $@, qr/larger than $cap octets/,
'... and the error names the limit' );
}

# The per-call parameter works too.
{
my $response = response_for( $bomb, undef );
my $lives = eval {
$response->decoded_content(
raise_error => 1,
max_body_size => $cap,
);
1;
};
is( $lives, undef, 'The max_body_size parameter caps a deflate body' );
}

# Without raise_error we get undef rather than a bomb.
{
my $response = response_for( $bomb, $cap );
ok( !defined $response->decoded_content( raise_error => 0 ),
'Without raise_error an over-large deflate body decodes to undef' );
}

# Content that fits under the cap still decodes correctly.
{
my $body = 'x' x 1000;

IO::Compress::Deflate::deflate( \$body, \my $small )
or die "Can't deflate content: $IO::Compress::Deflate::DeflateError";
my $response = response_for( $small, $cap );
is( $response->decoded_content( raise_error => 1 ), $body,
'A zlib-deflate body under the cap decodes correctly' );

IO::Compress::RawDeflate::rawdeflate( \$body, \my $small_raw )
or die
"Can't rawdeflate content: $IO::Compress::RawDeflate::RawDeflateError";
$response = response_for( $small_raw, $cap );
is( $response->decoded_content( raise_error => 1 ), $body,
'A raw-deflate body under the cap decodes correctly' );
}

# A negative limit is a caller error. It has to fail loudly: silently
# handing back empty content would look like a successful decode.
{
for my $limit ( -1, -1000 ) {
my $response = response_for( $bomb, $limit );
my $lives = eval {
$response->decoded_content( raise_error => 1 );
1;
};
is( $lives, undef, "A max_body_size of $limit is rejected" );
}
}

# Content exactly at the cap is not rejected.
{
my $body = 'x' x 1024;
IO::Compress::Deflate::deflate( \$body, \my $small )
or die "Can't deflate content: $IO::Compress::Deflate::DeflateError";
my $response = response_for( $small, 1024 );
is( $response->decoded_content( raise_error => 1 ), $body,
'A deflate body exactly at the cap decodes correctly' );
}

# Content that begins as a deflate stream but stops in the middle of one
# cannot be decoded either way round, with or without a limit in force. The
# decoders report that differently, so check both paths through the error
# handling.
{
my $body = 'x' x 10_000;
IO::Compress::RawDeflate::rawdeflate( \$body, \my $truncated )
or die
"Can't rawdeflate content: $IO::Compress::RawDeflate::RawDeflateError";
substr( $truncated, -5 ) = q{};

for my $limit ( undef, $cap ) {
my $with = defined $limit ? 'with' : 'without';
my $response = response_for( $truncated, $limit );
my $lives = eval {
$response->decoded_content( raise_error => 1 );
1;
};
is( $lives, undef,
"A truncated deflate stream is refused $with a limit" );
like(
$response->header('Client-Warning'),
qr/Could not raw inflate content/,
'... and the raw-deflate failure lands in Client-Warning'
);
}
}

# The raw-deflate fallback passes undecodable content through untouched
# (IO::Uncompress::RawInflate's Transparent mode). Keep that behaviour: such
# content is not amplified, so the cap has nothing to protect against.
{
my $body = 'this is not deflated at all';
my $response = response_for( $body, $cap );
is( $response->decoded_content( raise_error => 1 ), $body,
'Undecodable content is passed through unchanged' );
}

done_testing();
Loading