From b1fa67e712d0f4cb19e8ccd716dab83f9508330f Mon Sep 17 00:00:00 2001 From: Olaf Alders Date: Tue, 21 Jul 2026 20:52:50 +0000 Subject: [PATCH] Enforce max_body_size on the deflate path (CVE-2026-65060) decoded_content() did not apply max_body_size (or the global $HTTP::Message::MAXIMUM_BODY_SIZE) to "Content-Encoding: deflate", nor to its raw-deflate fallback, while gzip, x-gzip and bzip2 all honoured it. A hostile server could therefore bypass the limit entirely just by labelling a decompression bomb "deflate". Verified pre-patch: a 4GB bomb exhausts memory outright; post-patch it dies in 0.10s at 18MB RSS. Rather than reimplement inflate on Compress::Raw::Zlib, bound the existing IO::Uncompress decoder with a new _uncompress() helper. That keeps one decode path, so multi-member streams, trailing garbage and transparent pass-through all behave exactly as before and the cap is purely an added constraint. Confirmed byte-identical to the one-shot interface the unpatched code used, over eleven edge cases on perl 5.10, 5.12, 5.18 and 5.42. With a limit in force the decoder is asked for one octet more than the limit allows: enough to tell content that fits from content that does not, and never more memory than the caller has already agreed to hand over. With no limit the stream is read to its end a block at a time, the way 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. Also reject a negative max_body_size. Each decoder reacted to one differently and some quietly returned no content at all, which is indistinguishable from a successful decode. max_body_size() and $HTTP::Message::MAXIMUM_BODY_SIZE were undocumented; they now have Pod covering the bomb rationale, the block-sized overshoot, which encodings enforce the limit, why base64 and quoted-printable are exempt, and that br cannot distinguish an over-limit body from a corrupt one. GH#206, reported by grr. Co-Authored-By: Claude Opus 4.8 --- Changes | 8 ++ lib/HTTP/Message.pm | 105 +++++++++++++++++-- t/message-decode-deflatebomb.t | 180 +++++++++++++++++++++++++++++++++ 3 files changed, 285 insertions(+), 8 deletions(-) create mode 100644 t/message-decode-deflatebomb.t diff --git a/Changes b/Changes index 3a9f7ef4..46447798 100644 --- a/Changes +++ b/Changes @@ -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 diff --git a/lib/HTTP/Message.pm b/lib/HTTP/Message.pm index de2ed0d5..9072f467 100644 --- a/lib/HTTP/Message.pm +++ b/lib/HTTP/Message.pm @@ -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; + } + 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) = @_; @@ -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); @@ -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") { @@ -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 + +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 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, C, C, C
, C +and C encodings. It is not applied to content that needs no decoding, +nor to the charset decoding step. The C and C +encodings are also exempt: neither can expand its input, so neither can be +used to mount this kind of attack. + +Note that C
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 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 to decoded_content() so you can tell +the two cases apart. + =item $mess->decodable =item HTTP::Message::decodable() diff --git a/t/message-decode-deflatebomb.t b/t/message-decode-deflatebomb.t new file mode 100644 index 00000000..2488b40f --- /dev/null +++ b/t/message-decode-deflatebomb.t @@ -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();