]> Sergey Matveev's repositories - public-inbox.git/blob - t/httpd-corner.t
nntp: call SSL_shutdown in normal cases
[public-inbox.git] / t / httpd-corner.t
1 # Copyright (C) 2016-2019 all contributors <meta@public-inbox.org>
2 # License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
3 # note: our HTTP server should be standalone and capable of running
4 # generic PSGI/Plack apps.
5 use strict;
6 use warnings;
7 use Test::More;
8 use Time::HiRes qw(gettimeofday tv_interval);
9
10 foreach my $mod (qw(Plack::Util Plack::Builder
11                         HTTP::Date HTTP::Status IPC::Run)) {
12         eval "require $mod";
13         plan skip_all => "$mod missing for httpd-corner.t" if $@;
14 }
15
16 use Digest::SHA qw(sha1_hex);
17 use File::Temp qw/tempdir/;
18 use IO::Socket;
19 use IO::Socket::UNIX;
20 use Fcntl qw(:seek);
21 use Socket qw(IPPROTO_TCP TCP_NODELAY);
22 use POSIX qw(mkfifo);
23 require './t/common.perl';
24 my $tmpdir = tempdir('httpd-corner-XXXXXX', TMPDIR => 1, CLEANUP => 1);
25 my $fifo = "$tmpdir/fifo";
26 ok(defined mkfifo($fifo, 0777), 'created FIFO');
27 my $err = "$tmpdir/stderr.log";
28 my $out = "$tmpdir/stdout.log";
29 my $httpd = 'blib/script/public-inbox-httpd';
30 my $psgi = "./t/httpd-corner.psgi";
31 my %opts = (
32         LocalAddr => '127.0.0.1',
33         ReuseAddr => 1,
34         Proto => 'tcp',
35         Type => SOCK_STREAM,
36         Listen => 1024,
37 );
38 my $sock = IO::Socket::INET->new(%opts);
39 my $upath = "$tmpdir/s";
40 my $unix = IO::Socket::UNIX->new(
41         Listen => 1024,
42         Type => SOCK_STREAM,
43         Local => $upath
44 );
45 ok($unix, 'UNIX socket created');
46 my $pid;
47 END { kill 'TERM', $pid if defined $pid };
48 my $spawn_httpd = sub {
49         my (@args) = @_;
50         my $cmd = [ $httpd, @args, "--stdout=$out", "--stderr=$err", $psgi ];
51         $pid = spawn_listener(undef, $cmd, [ $sock, $unix ]);
52         ok(defined $pid, 'forked httpd process successfully');
53 };
54
55 {
56         ok($sock, 'sock created');
57         $spawn_httpd->('-W0');
58 }
59
60 {
61         my $conn = conn_for($sock, 'streaming callback');
62         $conn->write("GET /callback HTTP/1.0\r\n\r\n");
63         ok($conn->read(my $buf, 8192), 'read response');
64         my ($head, $body) = split(/\r\n\r\n/, $buf);
65         is($body, "hello world\n", 'callback body matches expected');
66 }
67
68 {
69         my $conn = conn_for($sock, 'getline-die');
70         $conn->write("GET /getline-die HTTP/1.1\r\nHost: example.com\r\n\r\n");
71         ok($conn->read(my $buf, 8192), 'read some response');
72         like($buf, qr!HTTP/1\.1 200\b[^\r]*\r\n!, 'got some sort of header');
73         is($conn->read(my $nil, 8192), 0, 'read EOF');
74         $conn = undef;
75         my $after = capture($err);
76         is(scalar(grep(/GETLINE FAIL/, @$after)), 1, 'failure logged');
77         is(scalar(grep(/CLOSE FAIL/, @$after)), 1, 'body->close not called');
78 }
79
80 {
81         my $conn = conn_for($sock, 'close-die');
82         $conn->write("GET /close-die HTTP/1.1\r\nHost: example.com\r\n\r\n");
83         ok($conn->read(my $buf, 8192), 'read some response');
84         like($buf, qr!HTTP/1\.1 200\b[^\r]*\r\n!, 'got some sort of header');
85         is($conn->read(my $nil, 8192), 0, 'read EOF');
86         $conn = undef;
87         my $after = capture($err);
88         is(scalar(grep(/GETLINE FAIL/, @$after)), 0, 'getline not failed');
89         is(scalar(grep(/CLOSE FAIL/, @$after)), 1, 'body->close not called');
90 }
91
92 {
93         my $conn = conn_for($sock, 'excessive header');
94         $SIG{PIPE} = 'IGNORE';
95         $conn->write("GET /callback HTTP/1.0\r\n");
96         foreach my $i (1..500000) {
97                 last unless $conn->write("X-xxxxxJunk-$i: omg\r\n");
98         }
99         ok(!$conn->write("\r\n"), 'broken request');
100         ok($conn->read(my $buf, 8192), 'read response');
101         my ($head, $body) = split(/\r\n\r\n/, $buf);
102         like($head, qr/\b400\b/, 'got 400 response');
103 }
104
105 {
106         my $conn = conn_for($sock, 'excessive body Content-Length');
107         $SIG{PIPE} = 'IGNORE';
108         my $n = (10 * 1024 * 1024) + 1;
109         $conn->write("PUT /sha1 HTTP/1.0\r\nContent-Length: $n\r\n\r\n");
110         ok($conn->read(my $buf, 8192), 'read response');
111         my ($head, $body) = split(/\r\n\r\n/, $buf);
112         like($head, qr/\b413\b/, 'got 413 response');
113 }
114
115 {
116         my $conn = conn_for($sock, 'excessive body chunked');
117         $SIG{PIPE} = 'IGNORE';
118         my $n = (10 * 1024 * 1024) + 1;
119         $conn->write("PUT /sha1 HTTP/1.1\r\nTransfer-Encoding: chunked\r\n");
120         $conn->write("\r\n".sprintf("%x\r\n", $n));
121         ok($conn->read(my $buf, 8192), 'read response');
122         my ($head, $body) = split(/\r\n\r\n/, $buf);
123         like($head, qr/\b413\b/, 'got 413 response');
124 }
125
126 {
127         my $conn = conn_for($sock, 'chunk with pipeline');
128         my $n = 10;
129         my $payload = 'b'x$n;
130         $conn->write("PUT /sha1 HTTP/1.1\r\nTransfer-Encoding: chunked\r\n");
131         $conn->write("\r\n".sprintf("%x\r\n", $n));
132         $conn->write($payload . "\r\n0\r\n\r\nGET /empty HTTP/1.0\r\n\r\n");
133         $conn->read(my $buf, 4096);
134         my $lim = 0;
135         $lim++ while ($conn->read($buf, 4096, bytes::length($buf)) && $lim < 9);
136         my $exp = sha1_hex($payload);
137         like($buf, qr!\r\n\r\n${exp}HTTP/1\.0 200 OK\r\n!s,
138                 'chunk parser can handled pipelined requests');
139 }
140
141 # Unix domain sockets
142 {
143         my $u = IO::Socket::UNIX->new(Type => SOCK_STREAM, Peer => $upath);
144         ok($u, 'unix socket connected');
145         $u->write("GET /host-port HTTP/1.0\r\n\r\n");
146         $u->read(my $buf, 4096);
147         like($buf, qr!\r\n\r\n127\.0\.0\.1:0\z!,
148                 'set REMOTE_ADDR and REMOTE_PORT for Unix socket');
149 }
150
151 sub conn_for {
152         my ($sock, $msg) = @_;
153         my $conn = IO::Socket::INET->new(
154                                 PeerAddr => $sock->sockhost,
155                                 PeerPort => $sock->sockport,
156                                 Proto => 'tcp',
157                                 Type => SOCK_STREAM);
158         ok($conn, "connected for $msg");
159         $conn->autoflush(1);
160         setsockopt($conn, IPPROTO_TCP, TCP_NODELAY, 1);
161         return $conn;
162 }
163
164 {
165         my $conn = conn_for($sock, 'host-port');
166         $conn->write("GET /host-port HTTP/1.0\r\n\r\n");
167         $conn->read(my $buf, 4096);
168         my ($head, $body) = split(/\r\n\r\n/, $buf);
169         my ($addr, $port) = split(/:/, $body);
170         is($addr, $conn->sockhost, 'host matches addr');
171         is($port, $conn->sockport, 'port matches');
172 }
173
174 # graceful termination
175 {
176         my $conn = conn_for($sock, 'graceful termination via slow header');
177         $conn->write("GET /slow-header HTTP/1.0\r\n" .
178                         "X-Check-Fifo: $fifo\r\n\r\n");
179         open my $f, '>', $fifo or die "open $fifo: $!\n";
180         $f->autoflush(1);
181         ok(print($f "hello\n"), 'wrote something to fifo');
182         my $kpid = $pid;
183         $pid = undef;
184         is(kill('TERM', $kpid), 1, 'started graceful shutdown');
185         ok(print($f "world\n"), 'wrote else to fifo');
186         close $f or die "close fifo: $!\n";
187         $conn->read(my $buf, 8192);
188         my ($head, $body) = split(/\r\n\r\n/, $buf, 2);
189         like($head, qr!\AHTTP/1\.[01] 200 OK!, 'got 200 for slow-header');
190         is($body, "hello\nworld\n", 'read expected body');
191         is(waitpid($kpid, 0), $kpid, 'reaped httpd');
192         is($?, 0, 'no error');
193         $spawn_httpd->('-W0');
194 }
195
196 {
197         my $conn = conn_for($sock, 'graceful termination via slow-body');
198         $conn->write("GET /slow-body HTTP/1.0\r\n" .
199                         "X-Check-Fifo: $fifo\r\n\r\n");
200         open my $f, '>', $fifo or die "open $fifo: $!\n";
201         $f->autoflush(1);
202         my $buf;
203         $conn->sysread($buf, 8192);
204         like($buf, qr!\AHTTP/1\.[01] 200 OK!, 'got 200 for slow-body');
205         like($buf, qr!\r\n\r\n!, 'finished HTTP response header');
206
207         foreach my $c ('a'..'c') {
208                 $c .= "\n";
209                 ok(print($f $c), 'wrote line to fifo');
210                 $conn->sysread($buf, 8192);
211                 is($buf, $c, 'got trickle for reading');
212         }
213         my $kpid = $pid;
214         $pid = undef;
215         is(kill('TERM', $kpid), 1, 'started graceful shutdown');
216         ok(print($f "world\n"), 'wrote else to fifo');
217         close $f or die "close fifo: $!\n";
218         $conn->sysread($buf, 8192);
219         is($buf, "world\n", 'read expected body');
220         is($conn->sysread($buf, 8192), 0, 'got EOF from server');
221         is(waitpid($kpid, 0), $kpid, 'reaped httpd');
222         is($?, 0, 'no error');
223         $spawn_httpd->('-W0');
224 }
225
226 sub delay { select(undef, undef, undef, shift || rand(0.02)) }
227
228 my $str = 'abcdefghijklmnopqrstuvwxyz';
229 my $len = length $str;
230 is($len, 26, 'got the alphabet');
231 my $check_self = sub {
232         my ($conn) = @_;
233         $conn->read(my $buf, 4096);
234         my ($head, $body) = split(/\r\n\r\n/, $buf, 2);
235         like($head, qr/\r\nContent-Length: 40\r\n/s, 'got expected length');
236         is($body, sha1_hex($str), 'read expected body');
237 };
238
239 SKIP: {
240         my $have_curl = 0;
241         foreach my $p (split(':', $ENV{PATH})) {
242                 -x "$p/curl" or next;
243                 $have_curl = 1;
244                 last;
245         }
246         my $ntest = 2;
247         $have_curl or skip('curl(1) missing', $ntest);
248         my $url = 'http://' . $sock->sockhost . ':' . $sock->sockport . '/sha1';
249         my ($r, $w);
250         pipe($r, $w) or die "pipe: $!";
251         my $cmd = [qw(curl --tcp-nodelay --no-buffer -T- -HExpect: -sS), $url];
252         my ($out, $err) = ('', '');
253         my $h = IPC::Run::start($cmd, $r, \$out, \$err);
254         $w->autoflush(1);
255         foreach my $c ('a'..'z') {
256                 print $w $c or die "failed to write to curl: $!";
257                 delay();
258         }
259         close $w or die "close write pipe: $!";
260         close $r or die "close read pipe: $!";
261         IPC::Run::finish($h);
262         is($?, 0, 'curl exited successfully');
263         is($err, '', 'no errors from curl');
264         is($out, sha1_hex($str), 'read expected body');
265 }
266
267 {
268         my $conn = conn_for($sock, '1.1 pipeline together');
269         $conn->write("PUT /sha1 HTTP/1.1\r\nUser-agent: hello\r\n\r\n" .
270                         "PUT /sha1 HTTP/1.1\r\n\r\n");
271         my $buf = '';
272         my @r;
273         until (scalar(@r) >= 2) {
274                 my $r = $conn->sysread(my $tmp, 4096);
275                 die $! unless defined $r;
276                 die "EOF <$buf>" unless $r;
277                 $buf .= $tmp;
278                 @r = ($buf =~ /\r\n\r\n([a-f0-9]{40})/g);
279         }
280         is(2, scalar @r, 'got 2 responses');
281         my $i = 3;
282         foreach my $hex (@r) {
283                 is($hex, sha1_hex(''), "read expected body $i");
284                 $i++;
285         }
286 }
287
288 {
289         my $conn = conn_for($sock, 'no TCP_CORK on empty body');
290         $conn->write("GET /empty HTTP/1.1\r\nHost:example.com\r\n\r\n");
291         my $buf = '';
292         my $t0 = [ gettimeofday ];
293         until ($buf =~ /\r\n\r\n/s) {
294                 $conn->sysread($buf, 4096, length($buf));
295         }
296         my $elapsed = tv_interval($t0, [ gettimeofday ]);
297         ok($elapsed < 0.190, 'no 200ms TCP cork delay on empty body');
298 }
299
300 {
301         my $conn = conn_for($sock, 'graceful termination during slow request');
302         $conn->write("PUT /sha1 HTTP/1.0\r\n");
303         delay();
304         $conn->write("Content-Length: $len\r\n");
305         delay();
306         $conn->write("\r\n");
307         my $kpid = $pid;
308         $pid = undef;
309         is(kill('TERM', $kpid), 1, 'started graceful shutdown');
310         delay();
311         my $n = 0;
312         foreach my $c ('a'..'z') {
313                 $n += $conn->write($c);
314         }
315         is($n, $len, 'wrote alphabet');
316         $check_self->($conn);
317         is(waitpid($kpid, 0), $kpid, 'reaped httpd');
318         is($?, 0, 'no error');
319         $spawn_httpd->('-W0');
320 }
321
322 # various DoS attacks against the chunk parser:
323 {
324         local $SIG{PIPE} = 'IGNORE';
325         my $conn = conn_for($sock, '1.1 chunk header excessive');
326         $conn->write("PUT /sha1 HTTP/1.1\r\nTransfer-Encoding:chunked\r\n\r\n");
327         my $n = 0;
328         my $w;
329         while ($w = $conn->write('ffffffff')) {
330                 $n += $w;
331         }
332         ok($!, 'got error set in $!');
333         is($w, undef, 'write error happened');
334         ok($n > 0, 'was able to write');
335         my $r = $conn->read(my $buf, 66666);
336         ok($r > 0, 'got non-empty response');
337         like($buf, qr!HTTP/1\.\d 400 !, 'got 400 response');
338
339         $conn = conn_for($sock, '1.1 chunk trailer excessive');
340         $conn->write("PUT /sha1 HTTP/1.1\r\nTransfer-Encoding:chunked\r\n\r\n");
341         is($conn->syswrite("1\r\na"), 4, 'wrote first header + chunk');
342         delay();
343         $n = 0;
344         while ($w = $conn->write("\r")) {
345                 $n += $w;
346         }
347         ok($!, 'got error set in $!');
348         ok($n > 0, 'wrote part of chunk end (\r)');
349         $r = $conn->read($buf, 66666);
350         ok($r > 0, 'got non-empty response');
351         like($buf, qr!HTTP/1\.\d 400 !, 'got 400 response');
352 }
353
354 {
355         my $conn = conn_for($sock, '1.1 chunked close trickle');
356         $conn->write("PUT /sha1 HTTP/1.1\r\nConnection:close\r\n");
357         $conn->write("Transfer-encoding: chunked\r\n\r\n");
358         foreach my $x ('a'..'z') {
359                 delay();
360                 $conn->write('1');
361                 delay();
362                 $conn->write("\r");
363                 delay();
364                 $conn->write("\n");
365                 delay();
366                 $conn->write($x);
367                 delay();
368                 $conn->write("\r");
369                 delay();
370                 $conn->write("\n");
371         }
372         $conn->write('0');
373         delay();
374         $conn->write("\r");
375         delay();
376         $conn->write("\n");
377         delay();
378         $conn->write("\r");
379         delay();
380         $conn->write("\n");
381         delay();
382         $check_self->($conn);
383 }
384
385 {
386         my $conn = conn_for($sock, '1.1 chunked close');
387         $conn->write("PUT /sha1 HTTP/1.1\r\nConnection:close\r\n");
388         my $xlen = sprintf('%x', $len);
389         $conn->write("Transfer-Encoding: chunked\r\n\r\n$xlen\r\n" .
390                 "$str\r\n0\r\n\r\n");
391         $check_self->($conn);
392 }
393
394 {
395         my $conn = conn_for($sock, 'chunked body + pipeline');
396         $conn->write("PUT /sha1 HTTP/1.1\r\n" .
397                         "Transfer-Encoding: chunked\r\n");
398         delay();
399         $conn->write("\r\n1\r\n");
400         delay();
401         $conn->write('a');
402         delay();
403         $conn->write("\r\n0\r\n\r\nPUT /sha1 HTTP/1.1\r\n");
404         delay();
405
406         my $buf = '';
407         until ($buf =~ /\r\n\r\n[a-f0-9]{40}\z/) {
408                 $conn->sysread(my $tmp, 4096);
409                 $buf .= $tmp;
410         }
411         my ($head, $body) = split(/\r\n\r\n/, $buf, 2);
412         like($head, qr/\r\nContent-Length: 40\r\n/s, 'got expected length');
413         is($body, sha1_hex('a'), 'read expected body');
414
415         $conn->write("Connection: close\r\n");
416         $conn->write("Content-Length: $len\r\n\r\n$str");
417         $check_self->($conn);
418 }
419
420 {
421         my $conn = conn_for($sock, 'trickle header, one-shot body + pipeline');
422         $conn->write("PUT /sha1 HTTP/1.0\r\n" .
423                         "Connection: keep-alive\r\n");
424         delay();
425         $conn->write("Content-Length: $len\r\n\r\n${str}PUT");
426         my $buf = '';
427         until ($buf =~ /\r\n\r\n[a-f0-9]{40}\z/) {
428                 $conn->sysread(my $tmp, 4096);
429                 $buf .= $tmp;
430         }
431         my ($head, $body) = split(/\r\n\r\n/, $buf, 2);
432         like($head, qr/\r\nContent-Length: 40\r\n/s, 'got expected length');
433         is($body, sha1_hex($str), 'read expected body');
434
435         $conn->write(" /sha1 HTTP/1.0\r\nContent-Length: $len\r\n\r\n$str");
436         $check_self->($conn);
437 }
438
439 {
440         my $conn = conn_for($sock, 'trickle body');
441         $conn->write("PUT /sha1 HTTP/1.0\r\n");
442         $conn->write("Content-Length: $len\r\n\r\n");
443         my $beg = substr($str, 0, 10);
444         my $end = substr($str, 10);
445         is($beg . $end, $str, 'substr setup correct');
446         delay();
447         $conn->write($beg);
448         delay();
449         $conn->write($end);
450         $check_self->($conn);
451 }
452
453 {
454         my $conn = conn_for($sock, 'one-shot write');
455         $conn->write("PUT /sha1 HTTP/1.0\r\n" .
456                         "Content-Length: $len\r\n\r\n$str");
457         $check_self->($conn);
458 }
459
460 {
461         my $conn = conn_for($sock, 'trickle header, one-shot body');
462         $conn->write("PUT /sha1 HTTP/1.0\r\n");
463         delay();
464         $conn->write("Content-Length: $len\r\n\r\n$str");
465         $check_self->($conn);
466 }
467
468 {
469         my $conn = conn_for($sock, '1.1 Connnection: close');
470         $conn->write("PUT /sha1 HTTP/1.1\r\nConnection:close\r\n");
471         delay();
472         $conn->write("Content-Length: $len\r\n\r\n$str");
473         $check_self->($conn);
474 }
475
476 {
477         my $conn = conn_for($sock, '1.1 pipeline start');
478         $conn->write("PUT /sha1 HTTP/1.1\r\n\r\nPUT");
479         my $buf = '';
480         until ($buf =~ /\r\n\r\n[a-f0-9]{40}\z/) {
481                 $conn->sysread(my $tmp, 4096);
482                 $buf .= $tmp;
483         }
484         my ($head, $body) = split(/\r\n\r\n/, $buf, 2);
485         like($head, qr/\r\nContent-Length: 40\r\n/s, 'got expected length');
486         is($body, sha1_hex(''), 'read expected body');
487
488         # 2nd request
489         $conn->write(" /sha1 HTTP/1.1\r\n\r\n");
490         $buf = '';
491         until ($buf =~ /\r\n\r\n[a-f0-9]{40}\z/) {
492                 $conn->sysread(my $tmp, 4096);
493                 $buf .= $tmp;
494         }
495         ($head, $body) = split(/\r\n\r\n/, $buf, 2);
496         like($head, qr/\r\nContent-Length: 40\r\n/s, 'got expected length');
497         is($body, sha1_hex(''), 'read expected body #2');
498 }
499
500 done_testing();
501
502 sub capture {
503         my ($f) = @_;
504         open my $fh, '+<', $f or die "failed to open $f: $!\n";
505         local $/ = "\n";
506         my @r = <$fh>;
507         truncate($fh, 0) or die "truncate failed on $f: $!\n";
508         \@r
509 }
510
511 1;