slowloris対策として、Apacheの2.1.15から入ったモジュールにmod_reqtimeoutというのがあります。
RequestReadTimeout header=10 body=30
このように設定することで、headerの受信が10秒以内、bodyの受信が30秒以内に完了しない場合、「408」エラーとできます。簡単で便利そうですね
公式ドキュメント http://httpd.apache.org/docs/2.2/en/mod/mod_reqtimeout.html
ただし、
Apacheをreverse proxyとして使用している場合はTimeoutにならず、リクエストの一部がproxy先に送られるという問題があるので注意が必要というか、はまったのでその話。
ちなみに、すでにBugzillaには上がっているけど、2.2系ではまだ対応完了してない https://issues.apache.org/bugzilla/show_bug.cgi?id=51103
以下再現方法。
まず、reverse proxy側で RequestReadTimeout と ProxyPass を設定します
RequestReadTimeout header=10 body=5
ProxyPass /backend http://127.0.0.1:5000
そして、backendとなるStarletには以下のpatchをあてます。
diff --git a/lib/Starlet/Server.pm b/lib/Starlet/Server.pm
index ea5a34a..f99f360 100644
--- a/lib/Starlet/Server.pm
+++ b/lib/Starlet/Server.pm
@@ -17,6 +17,8 @@ use Socket qw(IPPROTO_TCP TCP_NODELAY);
use Try::Tiny;
use Time::HiRes qw(time);
+use Log::Minimal;
+
use constant MAX_REQUEST_SIZE => 131072;
use constant MSWin32 => $^O eq 'MSWin32';
@@ -155,6 +157,10 @@ sub handle_connection {
undef $can_exit;
my $reqlen = parse_http_request($buf, $env);
if ($reqlen >= 0) {
+ {
+ local $Log::Minimal::AUTODUMP=1;
+ warnf $env;
+ }
# handle request
if ($use_keepalive) {
if (my $c = $env->{HTTP_CONNECTION}) {
@@ -177,6 +183,7 @@ sub handle_connection {
$conn, \$chunk, $cl, 0, $self->{timeout})
or return;
}
+warnf "chunk size: %d", length($chunk);
$buffer->print($chunk);
$cl -= length $chunk;
}
関係ないけどStarletはシンプルなのでこういう検証に便利ですね
そしてtest用のpsgiアプリケーションを書いて
use Plack::Request;
sub {
my $env = shift;
my $req = Plack::Request->new($env);
my $content = $req->content();
my $length = length($content);
[200,['Content-Type'=>'text/plain','Content-Length'=>length($length)+1],["$length\n"]];
};
起動、
$ plackup -s Starlet test.psgi
今度は、Bodyを送るのに5秒以上掛かるように細工したクライアントを書きます
#!/usr/bin/perl
use strict;
use warnings;
use IO::Socket::INET;
my $sock = IO::Socket::INET->new(
PeerAddr => '127.0.0.1',
PeerPort => '8080',
);
my $header = <<EOF;
POST /backend/foo HTTP/1.1
Host: example.com
User-Agent: slowpost
Content-Length: 32768
EOF
$header =~ s/\n/\r\n/g;
$sock->syswrite("$header\r\n");
for (1..8) {
$sock->syswrite("1"x4096);
sleep 1;
}
my $res;
$sock->sysread( $res, 8192 );
print $res;
これを使って、Apacheに向けてリクエストを送ってみると、、
$ perl slowpost.pl
HTTP/1.1 400 Bad Request
Date: Thu, 16 Feb 2012 02:28:51 GMT
Content-Length: 226
Connection: close
Content-Type: text/html; charset=iso-8859-1
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>400 Bad Request</title>
</head><body>
<h1>Bad Request</h1>
<p>Your browser sent a request that this server could not understand.<br />
</p>
</body></html>
Bodyの送信に5秒以上掛かっているので、期待としては「408」が返って来そうですが「400」となりました。
Apacheのエラーログには
[Thu Feb 16 11:28:56 2012] [error] proxy: pass request body failed to 127.0.0.1:5000 (127.0.0.1) from 127.0.0.1 ()
このように記録されています。
ふむふむ、bodyのproxyに失敗しただけかと思うのですが、実際にはbodyも一部だけがproxyされ途中で切れています。
以下がplackupを起動したターミナルの出力
2012-02-16T11:28:54 [WARN] {'psgi.multiprocess' => 1,'SCRIPT_NAME' => '','SERVER_NAME' => 0,'HTTP_CONNECTION' => 'close','HTTP_X_FORWARDED_SERVER' => 'kazeburomba.local','PATH_INFO' => '/foo','CONTENT_LENGTH' => '32768','REQUEST_METHOD' => 'POST','psgi.multithread' => '','HTTP_USER_AGENT' => 'slowpost','QUERY_STRING' => '','REMOTE_PORT' => 55700,'SERVER_PORT' => 5000,'psgix.input.buffered' => 1,'REMOTE_ADDR' => '127.0.0.1','HTTP_X_FORWARDED_HOST' => 'example.com','SERVER_PROTOCOL' => 'HTTP/1.1','HTTP_X_FORWARDED_FOR' => '127.0.0.1','psgi.streaming' => 1,'psgi.errors' => *::STDERR,'REQUEST_URI' => '/foo','psgi.version' => [1,1],'psgi.nonblocking' => '','psgix.io' => bless( \*Symbol::GEN1, 'IO::Socket::INET' ),'psgi.url_scheme' => 'http','psgi.run_once' => '','HTTP_HOST' => '127.0.0.1:5000'} at /Users/hoge/perl5/perlbrew/perls/perl-5.12.2/lib/site_perl/5.12.2/Starlet/Server.pm line 162
2012-02-16T11:28:54 [WARN] chunk size:16384 at /Users/hoge/perl5/perlbrew/perls/perl-5.12.2/lib/site_perl/5.12.2/Starlet/Server.pm line 184
ヘッダも送られてきて、Bodyも16384byte読み込んでいます。
実際、StarletではBody部分がContent-Lengthの長さにならなければリクエストの処理に移らないので、問題とはならないのですが、Content-Lengthと異なっていても正常に処理をしてしまった場合(libapreqを使うmod_perlがそうだった気がする)問題となる可能性があります。
こわいこわい
おそらく原因としては、mod_reqtimeoutもmod_proxyもfilterで動いていて、reqtimeoutがエラーと判断した時にはすでにmod_proxyがbackendにコンテンツを送ってしまっていて、mod_proxyでリクエストのBofyの続きが読めなくなって「400」エラーって事なんだろうなぁと思ってます。
対策としては、RequestReadTimeout を使わない、もしくは使う場所を限定する(アップロードなど通信時間長くなるところには使わない)、Content-LengthやChunked-Transferを正しく扱うサーバを使ってのが考えられます。
そういえば昔、同じような問題に、LimitRequestBodyでもあたったことがあって、ApacheのFilter機構は難しいなぁと思った次第。