mod_rewriteでの最後の砦

Webサーバ勉強会#3で話してきた内容です。mod_rewriteとmod_proxy_balancer使って、L7ロードバランサを作ろうとしたときにハマった話なんですが、すごい特定の限られた条件だし、そもそも大してクリティカルじゃないのですが、まぁまぁ面白い話題だと思うので紹介しておきます。

mod_proxyで最後の砦を作る時

ただのリバースプロキシだったり、L7ロードバランサだったりの用途で、Apacheのmod_proxy(mod_proxy_balancer)を使うことはたまにあると思います。RewriteMapとか、ルールのマッピングを外部ファイルにできたりするので結構柔軟に色々できます。

その際に色々と振り分けのルール付けを設定した後で、最後に「どれにも当てはまらない時はとりあえずここに振り分けさせる」というルールを書くときみなさんどうしてますか?Apacheのドキュメントとかにもよく登場するので、こういう書き方をする方は多いのではないでしょうか。

RewriteRule ^/(.*)$ http://backend/$1 [QSA,L,P]

説明するまでも無いと思いますが、「^/(.*)$」という正規表現でREQUEST_URI全てを拾って、$1で後方参照してそれをbackendへのREQUEST_URIにしています。フラグのQSAでQueryStringもそのまま付けてます(Lはこれで終わり、PはProxyの意味ですね)。

Apache2系のRewriteRuleの正規表現はPCRE(Perl互換正規表現)なので、おなじみのPerlの正規表現がそのまま使えるのでこれでどんなREQUEST_URIでも拾えそうな気がします。

でも、実はこれをすり抜ける方法があるんですよね。検証環境はCentOS5.5のhttpd-2.2.3です。

$ curl http://localhost/aa%0abbb
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>404 Not Found</title>
</head><body>
<h1>Not Found</h1>
<p>The requested URL /aa
bbb was not found on this server.</p>
</body></html>

$ tail -1 /etc/httpd/logs/error_log
[Thu Apr 28 02:19:34 2011] [error] [client 127.0.0.1] File does not exist: /var/www/html/proxy/aa\nbbb

ね?404が返ってきてしまっています。これはバックエンドのサーバの404ではなく、プロキシ用のApache自身のDocumentRootを探しにいった結果の404になっています。さっきの正規表現であればすべてをキャッチできるはずなのにどうしてこうなってしまったのでしょう。

rewriteのログを出しているので見てみます。

127.0.0.1 - - [28/Apr/2011:02:19:34 +0900] (2) init rewrite engine with requested uri /aa
bbb
127.0.0.1 - - [28/Apr/2011:02:19:34 +0900] (3) applying pattern '^/(.*)$' to uri '/aa
bbb'
127.0.0.1 - - [28/Apr/2011:02:19:34 +0900] (1) pass through /aa
bbb

applying patternの行がまさに正規表現のチェックの所になりますが、マッチしてなくて、なんか改行されてますね。

そう、先ほどcurlで渡したパスの中の「%0a」という部分は改行文字(LF)をパーセントエスケープしたものです。気持ち的には「%0a」自体はただのASCIIの文字列なので普通に正規表現にマッチしそうなのですが、実はmod_rewriteの中では正規表現の比較をするときにパーセントエスケープをデコードした文字列が使われている様です。ちょっとソースを覗いてみましょう。

static int apply_rewrite_rule(rewriterule_entry *p, rewrite_ctx *ctx)
{
    ap_regmatch_t regmatch[AP_MAX_REG_MATCH];
    apr_array_header_t *rewriteconds;
    rewritecond_entry *conds;
    int i, rc;
    char *newuri = NULL;
    request_rec *r = ctx->r;
    int is_proxyreq = 0;

    ctx->uri = r->filename;

...

    /* Try to match the URI against the RewriteRule pattern
     * and exit immediately if it didn't apply.
     */
    rewritelog((r, 3, ctx->perdir, "applying pattern '%s' to uri '%s'",
                p->pattern, ctx->uri));

    rc = !ap_regexec(p->regexp, ctx->uri, AP_MAX_REG_MATCH, regmatch, 0);
    if (! (( rc && !(p->flags & RULEFLAG_NOTMATCH)) ||
           (!rc &&  (p->flags & RULEFLAG_NOTMATCH))   ) ) {
        return 0;
    }

ctx->uriという値を正規表現でマッチさせる対象にしてますが、その前の方でr->filenameというのを代入してます。ここから先は深くは追いませんが、request_rec型はApache側で定義してるものらしく、filenameはパーセントエスケープをデコードした形になっているようです。(参考:mod_rewriteが扱うパスはデコードされている – るびゅ備忘録)

さて、ではなぜ改行文字が入っていると^/(.*)$にマッチしないのでしょうか?先ほども書きましたが、Apache2系からはmod_rewriteの正規表現はPCREライブラリになっています。PCREにおいて、「.」は改行文字以外の任意の文字にマッチするんですね。

. match any character except newline (by default)

www.pcre.org/pcre.txt

そう、^/(.*)$は日本語にすると「行頭から行末まで改行文字以外の任意の文字0文字以上」なので、%0aがデコードされた文字が途中に含まれているとマッチしません(ちなみに%0aが一番最後だとちょうど行末になるのでマッチします)。

まぁ、普通こんな文字がURLに含まれる事は少ないでしょうし、変なものは404返してしまっても別にいいかと思うんですが、僕がたまたま相手にしてたバックエンドの処理系が色々頭おかしい感じだってたので、プロキシ挟んだ時だけエラーになるという状態になってしまいました。

まず、ユーザの投稿がGETメソッドのクエリストリングとして渡されてたんですが、なぜか一番最初を?にしてませんでした。

GET /post&body=aaaa%0abbb&title=...

そのため、mod_rewrite的には意図としてはクエリストリングにしたかった内容までパスとして判断されてしまってました。これだけならそもそも指定が間違ってるのでエラーでいいんですが、なぜかバックエンドの処理系はこれをちゃんとクエリストリングとして分解して処理してたんですよね。。。RFCどこいったって感じなんですが、ともかくなぜかこんなので今まで動いてしまっていたので、プロクシはさんだらエラーになってしまい、こんなしょうもない調査をしたという次第です。

あと、ちなみにApache1.3とかだと正規表現のライブラリがPOSIX準拠らしく、「.」が改行にもマッチするので挙動が変わったりするのでバージョンをいきなり変えると困ったりともうわけわかめ。。。

最後の砦として機能させるには?

愚痴はおいといて、Apache2系でこの「%0a」が含まれるリクエストに対しても最後の砦を機能させるにはどうするのがよいのでしょうか?mod_rewriteの中でデコードされてしまう問題に対してデコードせずに正規表現にマッチさせるにはもはやパッチを書くしかないわけですが、それを除くとこういう方法があります。

PCREのマニュアルをよくみると、こんなオプションがあります(当然Perlの正規表現でも)。

(?s) single line (dotall)

www.pcre.org/pcre.txt

つまり、このオプションをつけてあげることで「.」を改行文字にもマッチさせられる様です。したがって、先ほどのRewriteRuleをこんな感じにしてあげると無事拾えます。

RewriteRule (?s)^/(.*)$ http://backend/$1 [QSA,L,P]

これで完璧?

と思ったんですが、色々とハメがあっておかげで発表スライド作れませんでした、すいませんすいません><

上記の通り、一度デコードされてしまうんで、例えば「%2f(ASCIIの「/」)」とかがあるとすると、いったいそれがもともと「/」で入ってたのか、「%2f」で入ってたのかわからなくなってしまうんですよね。

例えば上記のRewriteRuleのフラグだとこんな感じ。REQUEST_URIの部分はバックエンドのCGIが受け取った内容です。

RewriteRule (?s)^/(.*)$ http://backend/$1 [QSA,L,P]

$ curl http://localhost/aaa/aaa%2faaa
REQUEST_URI = /aaa/aaa/aaa

一方、CentOS5のApache2.2.3だと無いんですが、最近のApache2.2系だとついてるBフラグ(後方参照時にエスケープ)を使うとこんな感じ。そのエスケープの%がエスケープされないようにNEもつけてます。

RewriteRule (?s)^/(.*)$ http://backend/$1 [QSA,L,P,B,NE]

$ curl http://localhost/aaa/aaa%2faaa
REQUEST_URI = /aaa%2faaa%2faaa

うーむ。ちゃんとソース見れてないですが、一度「r->filename」になってしまった状態では、出自がどっちなのか理解できませんから、きっとこれに対処するにはmod_rewriteにデコードしないようなパッチを当てるしかないような気がします。。。

おわりに

というわけで、ホントは「こうすればmod_rewriteでのプロキシ完璧」ってのを書きたかったんですが、ちょっと時間と実力不足で中途半端になってしまいました。まぁ、そもそも想定してない変なURLについては404返してしまえばいいだけなので、もはやどうでもいい話題なんですが、ちょっとくやしい。。いい解決方法があれば誰か教えてくだしあ><