ロードバランサを筆頭に最近のWEB開発の現場では Proxy サーバがよく出てきます。で Proxy サーバが存在するとよく問題になるのが「ユーザの本当の接続元IP」を知りたいという要求で、通常それは Proxy サーバがバックエンドのリクエストに X-Forwarded-For
ヘッダを付けてくれることで大体解決します。でその値を更に便利に使えるようにしてくれる mod_remoteip
ってのがあって、今まではほぼメリット部分が大きく、その副作用で困ることはあまりなかったんですが、今回ちょっとハマったケースがあったので備忘録としてその内容を記録しておく。
X-Forwarded-For ヘッダの役割と仕組み
X-Forwarded-For
ヘッダは ip1[, ip2[, ip3[, ...]]]
といった構造の値が入ることになっていて、ip1が「ユーザが最初に直接アクセスしたProxyサーバから見た接続元IP=ユーザのIP」で、ip2 は「2番目のProxyサーバから見た接続元IP=最初のProxyサーバのIP」で、Proxyサーバが多段になっている場合は各Proxyサーバはリクエストヘッダの値の後ろにカンマ区切りで直前のIP情報を足したものをバックエンドに投げることになっており、最奥に居るバックエンドサーバでもどんな接続元IPを辿ってここまでリクエストが届いたのかの情報を知ることが出来る。というものです。
ただ、通常はあいだのProxyサーバのIPはどうでも良くて一番左に書いてある「ユーザのIP」情報だけが必要な事がほとんどです。でも X-Forwarded-For
の値を直接使おうとすると比較的簡単とはいえ値全体をパースして最初のIPを取り出してやるという操作が必要で面倒な上に、WEBサーバに接続元IPによる何らかの制御機能があってもそのまま使えないし、ヘッダから取得した値を愚直に使うと更に面倒な設定を書かないといけなかったりで割と面倒です。
mod_remoteip は何をしてくれるのか?
で、それを Apache httpd では mod_remoteip が解決してくれます。mod_remoteip が使える状態では以下のような設定を書くことで直接の接続元がローカルネットワーク内なら X-Fowarded-For を信用して先頭のIPをユーザIPとして(REMOTE_ADDR)普通に設定やアプリ内で使えるようにするという仕組みです。
# X-Forwarded-For の値を信用して最初のIPをクライアントIPとして利用する
RemoteIPHeader X-Forwarded-For
RemoteIPTrustedProxy 127.0.0.1/8
RemoteIPTrustedProxy 10.0.0.0/8
RemoteIPTrustedProxy 172.16.0.0/12
RemoteIPTrustedProxy 192.168.0.0/16
こんな感じの設定をすると、httpd.conf 内では mod_authz_host
の Require ip xxx
が比較対象にするIPや、 RewriteCond や expr など各所で利用可能な変数 %{REMOTE_ADDR}
の値、アクセスログ内のリモートIP変数 %a
の値、PHP等のプログラムから利用できる REMOTE_ADDR の値、などのまるっと全部の値が X-Forwarded-For
を信じて取得たクライアントIPの値に置き換わってくれる。もちろんHTTPヘッダなんてクライアント側で好きに書けるからそのまま鵜呑みにするのはまずいが、「不正な値は通さない」という事がちゃんと実践されている信頼可能なProxyサーバだけなら連鎖的に信頼可能なので、その信頼可能な直近のProxyサーバのIPを列挙も重要な点です。ただ、多くのケースではプライベートアドレスを信頼しとけば大体大丈夫なのでコピペで使える設定でもある。
結構影響範囲が大きいように見えるが、実際使ってみれば、直前のプロキシサーバのIPよりユーザのIPの方が利用価値はとても高いうえに、結構網羅的に リモートIP
を書き換えてくれるので騙された者同士での不整合が出ることも殆ど無くて、基本的にはメリットのほうが大きかった。
問題発生! RemoteIPHeader を書く場所に注意。
出来るだけ最初の方に書くのがベストです。
僕の最初の予想では RemoteIPHeader X-Forwarded-For
をどこでもよいから設定してあれば IP 書き換えが効くもんだと思ってたんですが
-
RemoteIPHeader xxx
より後ろに書いた設定ではREMOTE_ADDR
の書き換えが効く(ユーザIPが接続元として扱われる) -
RemoteIPHeader xxx
をより前に書いた設定ではまだREMOTE_ADDR
の書き換えは効かない!? -
CustomLog
やLogFormat
は上にあろうが下にあろうがログに出力されるIP書き換えは効く。 - 元々の
X-Forwarded-For
に関しては削除されるので元のヘッダに書いてあった値は取得できない。これは上でも下でも!?
つまりこういう事です。
# User1=1.1.1.1, → Proxy1=10.0.0.1 → Proxy2=10.0.0.2 → Server1=10.0.0.3
# なリクエストがあると Server1 では通常は以下のような接続元IPとヘッダが取得できるはずです。
#
# %{REMOTE_ADDR}: 10.0.0.3
# X-Forwarded-For: 1.1.1.1, 10.0.0.1, 10.0.0.2
#
SetEnvIf REMOTE_ADDR 1.1.1.1 remote1=user1
SetEnvIf REMOTE_ADDR 10.0.0.3 remote1=proxy2
SetEnvIf X-Forwarded-For (.*) xff1=$1
RemoteIPHeader X-Forwarded-For
RemoteIPTrustedProxy 10.0.0.0/8
SetEnvIf REMOTE_ADDR 1.1.1.1 remote2=user1
SetEnvIf REMOTE_ADDR 10.0.0.3 remote2=proxy2
SetEnvIf X-Forwarded-For (.*) xff2=$1
これを phpinfo() とかで見ると4つの環境変数の値は色々予想外ですが以下のようになります!?
remote1: proxy2 … まさかのまだ書き換えが効いてない!?
remote2: user1 … これは期待した値
xff1: (null) … 消えるだけならxff2と同じで納得するが、beforeの場所だとIPそのままなのに
xffヘッダだけ先に消されちゃってるという自体が発生しちゃう!!
xff2: (null) … これは予想の範囲内(接続元IPが書き換わってるから消しちゃおうってのはわからんでもない)
今回僕のもとで起きた問題の詳細
実は今回起きた問題ってのはこの RemoteIPHeader より前の場所にある設定で起きたんだ。どんな問題かというとユーザのIPが 1.1.1.1 なら環境変数を設定して他で使うってことをやりたかったんだ。しかも元々は Proxy があってもなくても期待した動作になるように以下のような2つの設定がしてあって、Proxyがあろうとなかろうとちゃんと動いてたんだ。
# 接続元が 1.1.1.1 だったら remote_is_user1 をセットする
SetEnvIf REMOTE_ADDR ^1\.1\.1\.1$ remote_is_user1
SetEnvIf X-Forwarded-For ^1\.1\.1\.1(,.*)?$ remote_is_user1
そこに mod_remoteip の設定を以下のようなファイルで追加したのさ。
RemoteIPHeader X-Forwarded-For
RemoteIPTrustedProxy 10.0.0.0/8
その結果こうなった。
-
IncludeOptional conf.d/*.conf
が元々あったから両方の設定ファイルが読み込まれる。 - ただしファイル名順で読まれるため上記設定のように RemoteIPHeader 設定が後に読み込まれてしまう。
- REMOTE_ADDR をチェックしたときはまだIP書き換えはされてないからProxyサーバのIPである 10.0.0.2 が入ってるのでチェックに漏れる
- X-Forwarded-For ヘッダは RemoteIPHeader の設定行まで到達してないけど一度最初に httpd.conf 全体が読み込まれた時点で RemoteIPHeader 設定の存在によって先に消されてしまってるのでやっぱりチェックが漏れる
- 結果として 1.1.1.1 からの接続なのに remote_is_user1 が設置されなかった。
という問題が起きた。
今後の対策
そもそもの原因は「mod_remoteip がIP書き換えをするタイミングと X-Forwarded-Forヘッダを削除するタイミングが異なる」というのが最大の原因で、これはもうバグだろうから後で Issue 投げつけたい。でもバグが直るのを待ってるわけにも行かないので問題を解決するための次善の策としてユーザに今出来ることとしてはコレ。
「RemoteIPHeader
ディレクティブは httpd.conf のなるべく最初の方に書こう!」
ってことだ。
特に conf.d/*.conf
の中に放り込むのは最大のNGだ。同じ conf.d/*.conf
の中でIPチェックを行うような設定が混ざってようものなら…、設定内容は同じなのに分割保存したときのファイル名によって挙動が変わるとかいう、出会いたくないタイプのバグ(障害)に発展してしまうという危険があるから!
今回は本当によくもそんな見事に色んな要素がいい感じで積み上がってバグが発現してくれたもんだわって思った…。