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 返してしまえばいいだけなので、もはやどうでもいい話題なんですが、ちょっとくやしい。。いい解決方法があれば誰か教えてくだしあ><