Skip to content

Commit 7fc855e

Browse files
Max Maischeinoalders
authored andcommitted
Add ->max_body_size accessor
Limit decoded body size by manually decoding the compressed content This creates one (more) copy of the content if we limit the output because Zlib and Bzip2 want to remove the consumed input from the input string. Also, this moves away from IO::Uncompress::Gunzip and IO::Uncompress::Bzip2 in favour of Compress::Raw::Zlib and Compress::Raw::Bzip2 because I found no way to convince IO::Uncompress::Gunzip::gunzip to pass through the appropriate limiting options. The API is extended (but not yet documented) in three ways: 1) A global variable, $HTTP::Message::MAX_BODY_SIZE to limit the maximum size of ->decoded_content 2) An accessor, ->max_body_size, which can be set for individual HTTP::Responses 3) An optional parameter to ->decoded_content, which certainly is the most preferrable option but requires cooperation from all locations where ->decoded_content is called. Output the Compress::Raw::Zlib version in case a test fails We might be fine with version 2.061... Up our prerequisite to 2.061 for the time being... Update META.json as well... Amend changes * Eliminate use of wantarray() in ->max_body_size * Eliminate use of vars.pm Also handle Brotli (de)compression Reindent to match source Only run zipbomb tests if we have a recent version of Compress::Raw::Zlib The Bufsize parameter was introduced in 2.060, so using 2.061 should be fairly safe here Remove debugging comments, remove misleading comments In #181 , #181 (review) Add Changes blurb ... mostly to pacify the gods of CI
1 parent f69d463 commit 7fc855e

7 files changed

Lines changed: 401 additions & 10 deletions

File tree

Changes

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ Revision history for HTTP-Message
77
from 2048 to 8192. This was suggested in RT#105184, as it improved
88
performance for them. (GH#59) (Neil Bowers)
99

10+
6.41 2022-10-12 15:57:40Z
11+
- Add maximum size for HTTP::Message->decoded_content
12+
This can be used to limit the size of a decompressed HTTP response,
13+
especially when making requests to untrusted or user-specified servers.
14+
The $HTTP::Message::MAXIMUM_BODY_SIZE variable and the ->max_body_size
15+
accessor can set this limit. (GH#181) (Max Maischein)
16+
1017
6.40 2022-10-12 15:45:52Z
1118
- Fixed two typos in the doc, originally reported by FatherC
1219
in RT#90716, ported over as GH#57. (GH#57) (Neil Bowers)

META.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
"IO::Compress::Bzip2" : "2.021",
6262
"IO::Compress::Deflate" : "0",
6363
"IO::Compress::Gzip" : "0",
64+
"Compress::Raw::Zlib" : "2.061",
6465
"IO::HTML" : "0",
6566
"IO::Uncompress::Bunzip2" : "2.021",
6667
"IO::Uncompress::Gunzip" : "0",

Makefile.PL

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ my %WriteMakefileArgs = (
3232
"IO::Uncompress::Gunzip" => 0,
3333
"IO::Uncompress::Inflate" => 0,
3434
"IO::Uncompress::RawInflate" => 0,
35+
"Compress::Raw::Zlib" => 2.061,
3536
"LWP::MediaTypes" => 6,
3637
"MIME::Base64" => "2.1",
3738
"MIME::QuotedPrint" => 0,

lib/HTTP/Message.pm

Lines changed: 74 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ our $VERSION = '6.41';
88
require HTTP::Headers;
99
require Carp;
1010

11+
our $MAXIMUM_BODY_SIZE;
12+
1113
my $CRLF = "\015\012"; # "\r\n" is not portable
1214
unless ($HTTP::URI_CLASS) {
1315
if ($ENV{PERL_HTTP_URI_CLASS}
@@ -53,10 +55,10 @@ sub new
5355
bless {
5456
'_headers' => $header,
5557
'_content' => $content,
58+
'_max_body_size' => $HTTP::Message::MAXIMUM_BODY_SIZE,
5659
}, $class;
5760
}
5861

59-
6062
sub parse
6163
{
6264
my($class, $str) = @_;
@@ -277,6 +279,17 @@ sub content_charset
277279
return undef;
278280
}
279281

282+
sub max_body_size {
283+
my $self = $_[0];
284+
my $old = $self->{_max_body_size};
285+
$self->_set_max_body_size($_[1]) if @_ > 1;
286+
return $old;
287+
}
288+
289+
sub _set_max_body_size {
290+
my $self = $_[0];
291+
$self->{_max_body_size} = $_[1];
292+
}
280293

281294
sub decoded_content
282295
{
@@ -288,34 +301,85 @@ sub decoded_content
288301
$content_ref = $self->content_ref;
289302
die "Can't decode ref content" if ref($content_ref) ne "SCALAR";
290303

304+
my $content_limit = exists $opt{ max_body_size } ? $opt{ max_body_size }
305+
: defined $self->max_body_size ? $self->max_body_size
306+
: undef
307+
;
308+
my %limiter_options;
309+
if( defined $content_limit ) {
310+
%limiter_options = (LimitOutput => 1, Bufsize => $content_limit);
311+
};
291312
if (my $h = $self->header("Content-Encoding")) {
292313
$h =~ s/^\s+//;
293314
$h =~ s/\s+$//;
294315
for my $ce (reverse split(/\s*,\s*/, lc($h))) {
295316
next unless $ce;
296317
next if $ce eq "identity" || $ce eq "none";
297318
if ($ce eq "gzip" || $ce eq "x-gzip") {
298-
require IO::Uncompress::Gunzip;
299-
my $output;
300-
IO::Uncompress::Gunzip::gunzip($content_ref, \$output, Transparent => 0)
301-
or die "Can't gunzip content: $IO::Uncompress::Gunzip::GunzipError";
319+
require Compress::Raw::Zlib; # 'WANT_GZIP_OR_ZLIB', 'Z_BUF_ERROR';
320+
321+
if( ! $content_ref_iscopy and keys %limiter_options) {
322+
# Create a copy of the input because Zlib will overwrite it
323+
# :-(
324+
my $input = "$$content_ref";
325+
$content_ref = \$input;
326+
$content_ref_iscopy++;
327+
};
328+
my ($i, $status) = Compress::Raw::Zlib::Inflate->new(
329+
%limiter_options,
330+
ConsumeInput => 0, # overridden by Zlib if we have %limiter_options :-(
331+
WindowBits => Compress::Raw::Zlib::WANT_GZIP_OR_ZLIB(),
332+
);
333+
my $res = $i->inflate( $content_ref, \my $output );
334+
$res == Compress::Raw::Zlib::Z_BUF_ERROR()
335+
and Carp::croak("Decoded content would be larger than $content_limit octets");
336+
$res == Compress::Raw::Zlib::Z_OK()
337+
or $res == Compress::Raw::Zlib::Z_STREAM_END()
338+
or die "Can't gunzip content: $res";
302339
$content_ref = \$output;
303340
$content_ref_iscopy++;
304341
}
305342
elsif ($ce eq 'br') {
306343
require IO::Uncompress::Brotli;
307344
my $bro = IO::Uncompress::Brotli->create;
308-
my $output = eval { $bro->decompress($$content_ref) };
345+
346+
my $output;
347+
if( defined $content_limit ) {
348+
$output = eval { $bro->decompress( $$content_ref, $content_limit ); }
349+
} else {
350+
$output = eval { $bro->decompress($$content_ref) };
351+
}
352+
309353
$@ and die "Can't unbrotli content: $@";
310354
$content_ref = \$output;
311355
$content_ref_iscopy++;
312356
}
313357
elsif ($ce eq "x-bzip2" or $ce eq "bzip2") {
314-
require IO::Uncompress::Bunzip2;
358+
require Compress::Raw::Bzip2;
359+
360+
if( ! $content_ref_iscopy ) {
361+
# Create a copy of the input because Bzlib2 will overwrite it
362+
# :-(
363+
my $input = "$$content_ref";
364+
$content_ref = \$input;
365+
$content_ref_iscopy++;
366+
};
367+
my ($i, $status) = Compress::Raw::Bunzip2->new(
368+
1, # appendInput
369+
0, # consumeInput
370+
0, # small
371+
$limiter_options{ LimitOutput } || 0,
372+
);
315373
my $output;
316-
IO::Uncompress::Bunzip2::bunzip2($content_ref, \$output, Transparent => 0)
317-
or die "Can't bunzip content: $IO::Uncompress::Bunzip2::Bunzip2Error";
318-
$content_ref = \$output;
374+
$output = "\0" x $limiter_options{ Bufsize }
375+
if $limiter_options{ Bufsize };
376+
my $res = $i->bzinflate( $content_ref, \$output );
377+
$res == Compress::Raw::Bzip2::BZ_OUTBUFF_FULL()
378+
and Carp::croak("Decoded content would be larger than $content_limit octets");
379+
$res == Compress::Raw::Bzip2::BZ_OK()
380+
or $res == Compress::Raw::Bzip2::BZ_STREAM_END()
381+
or die "Can't bunzip content: $res";
382+
$content_ref = \$output;
319383
$content_ref_iscopy++;
320384
}
321385
elsif ($ce eq "deflate") {

t/message-decode-brotlibomb.t

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# https://rt.cpan.org/Public/Bug/Display.html?id=52572
2+
3+
use strict;
4+
use warnings;
5+
6+
use Test::More;
7+
8+
use HTTP::Headers qw();
9+
use HTTP::Response qw();
10+
11+
my $ok = eval {
12+
require IO::Compress::Brotli;
13+
require IO::Uncompress::Brotli;
14+
1;
15+
};
16+
if(! $ok) {
17+
plan skip_all => "IO::Compress::Brotli needed; $@";
18+
exit
19+
}
20+
plan tests => 9;
21+
22+
# Create a nasty brotli stream:
23+
my $size = 16 * 1024 * 1024;
24+
my $stream = "\0" x $size;
25+
26+
# Compress that stream one time (since it won't compress it twice?!):
27+
my $compressed = $stream;
28+
my $bro = IO::Compress::Brotli->create;
29+
30+
for( 1 ) {
31+
my $last = $compressed;
32+
$compressed = $bro->compress( $compressed );
33+
$compressed .= $bro->finish();
34+
note sprintf "Encoded size %d bytes after round %d", length $compressed, $_;
35+
};
36+
37+
my $body = $compressed;
38+
39+
my $headers = HTTP::Headers->new(
40+
Content_Type => "application/xml",
41+
Content_Encoding => 'br', # only one round needed for Brotli
42+
);
43+
my $response = HTTP::Response->new(200, "OK", $headers, $body);
44+
45+
my $len = length $response->decoded_content;
46+
is($len, 16 * 1024 * 1024, "Self-test: The decoded content length is 16M as expected" );
47+
48+
# Manual decompression check
49+
my $output = $compressed;
50+
for( 1 ) {
51+
my $unbro = IO::Uncompress::Brotli->create();
52+
$output = $unbro->decompress($compressed);
53+
};
54+
55+
$headers = HTTP::Headers->new(
56+
Content_Type => "application/xml",
57+
Content_Encoding => 'br' # say my name, but only once
58+
);
59+
60+
$HTTP::Message::MAXIMUM_BODY_SIZE = 1024 * 1024;
61+
62+
$response = HTTP::Response->new(200, "OK", $headers, $body);
63+
is $response->max_body_size, 1024*1024, "The default maximum body size holds";
64+
65+
$response->max_body_size( 512*1024 );
66+
is $response->max_body_size, 512*1024, "We can change the maximum body size";
67+
68+
my $content;
69+
my $lives = eval {
70+
$content = $response->decoded_content( raise_error => 1 );
71+
1;
72+
};
73+
my $err = $@;
74+
is $lives, undef, "We die when trying to decode something larger than our global limit of 512k"
75+
or diag "... using IO::Uncompress::Brotli version $IO::Uncompress::Brotli::VERSION";
76+
77+
$response->max_body_size(undef);
78+
is $response->max_body_size, undef, "We can remove the maximum size restriction";
79+
$lives = eval {
80+
$content = $response->decoded_content( raise_error => 0 );
81+
1;
82+
};
83+
is $lives, 1, "We don't die when trying to decode something larger than our global limit of 1M";
84+
is length $content, 16 * 1024*1024, "We get the full content";
85+
is $content, $stream, "We really get the full content";
86+
87+
# The best usage of ->decoded_content:
88+
$lives = eval {
89+
$content = $response->decoded_content(
90+
raise_error => 1,
91+
max_body_size => 512 * 1024 );
92+
1;
93+
};
94+
$err = $@;
95+
is $lives, undef, "We die when trying to decode something larger than our limit of 512k using a parameter"
96+
or diag "... using IO::Uncompress::Brotli version $IO::Uncompress::Brotli::VERSION";
97+
98+
=head1 SEE ALSO
99+
100+
L<https://security.stackexchange.com/questions/51071/zlib-deflate-decompression-bomb>
101+
102+
L<http://www.aerasec.de/security/advisories/decompression-bomb-vulnerability.html>
103+
104+
=cut

t/message-decode-bzipbomb.t

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# https://rt.cpan.org/Public/Bug/Display.html?id=52572
2+
3+
use strict;
4+
use warnings;
5+
6+
use Test::More;
7+
plan tests => 10;
8+
9+
use HTTP::Headers qw( );
10+
use HTTP::Response qw( );
11+
12+
# Create a nasty bzip2 stream:
13+
my $size = 16 * 1024 * 1024;
14+
my $stream = "\0" x $size;
15+
16+
# Compress that stream three times:
17+
my $compressed = $stream;
18+
for( 1..3 ) {
19+
require IO::Compress::Bzip2;
20+
my $last = $compressed;
21+
IO::Compress::Bzip2::bzip2(\$last, \$compressed)
22+
or die "Can't bzip2 content: $IO::Compress::Bzip2::Bzip2Error";
23+
#diag sprintf "Encoded size %d bytes after round %d", length $compressed, $_;
24+
};
25+
26+
my $body = $compressed;
27+
28+
my $headers = HTTP::Headers->new(
29+
Content_Type => "application/xml",
30+
Content_Encoding => 'bzip2,bzip2,bzip2', # say my name three times
31+
);
32+
my $response = HTTP::Response->new(200, "OK", $headers, $body);
33+
34+
my $len = length $response->decoded_content( raise_error => 1 );
35+
is($len, 16 * 1024 * 1024, "Self-test: The decoded content length is 16M as expected" );
36+
37+
# Manual decompression check
38+
my $output = $compressed;
39+
for( 1..3 ) {
40+
my $last = $output;
41+
require Compress::Raw::Bzip2;
42+
my ($i, $status) = Compress::Raw::Bunzip2->new(
43+
1, # appendInput
44+
0, # consumeInput
45+
0, # small
46+
1,
47+
);
48+
$output = "\0" x (1024*1024);
49+
# Will modify $last, but we made a copy above
50+
my $res = $i->bzinflate( \$last, \$output );
51+
};
52+
is length $output, 1024*1024, "We manually recreate the limited original stream";
53+
54+
$headers = HTTP::Headers->new(
55+
Content_Type => "application/xml",
56+
Content_Encoding => 'bzip2,bzip2,bzip2', # say my name three times
57+
);
58+
59+
$HTTP::Message::MAXIMUM_BODY_SIZE = 1024 * 1024;
60+
61+
$response = HTTP::Response->new(200, "OK", $headers, $body);
62+
is $response->max_body_size, 1024*1024, "The default maximum body size holds";
63+
64+
$response->max_body_size( 512*1024 );
65+
is $response->max_body_size, 512*1024, "We can change the maximum body size";
66+
67+
my $content;
68+
my $lives = eval {
69+
$content = $response->decoded_content( raise_error => 1 );
70+
1;
71+
};
72+
my $err = $@;
73+
is $lives, undef, "We die when trying to decode something larger than our limit of 512k";
74+
75+
$response->max_body_size(undef);
76+
is $response->max_body_size, undef, "We can remove the maximum size restriction";
77+
$lives = eval {
78+
$content = $response->decoded_content( raise_error => 0 );
79+
1;
80+
};
81+
is $lives, 1, "We don't die when trying to decode something larger than our global limit of 1M";
82+
is length $content, 16 * 1024*1024, "We get the full content";
83+
is $content, $stream, "We really get the full content";
84+
85+
# The best usage of ->decoded_content:
86+
$lives = eval {
87+
$content = $response->decoded_content(
88+
raise_error => 1,
89+
max_body_size => 512 * 1024 );
90+
1;
91+
};
92+
$err = $@;
93+
is $lives, undef, "We die when trying to decode something larger than our limit of 512k";
94+
95+
=head1 SEE ALSO
96+
97+
L<https://security.stackexchange.com/questions/51071/zlib-deflate-decompression-bomb>
98+
99+
L<http://www.aerasec.de/security/advisories/decompression-bomb-vulnerability.html>
100+
101+
=cut

0 commit comments

Comments
 (0)