エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

こんばんは、X-Forwarded-For警察です

エムスリーエンジニアリンググループ製薬企業向けプラットフォームチームの三浦 (@yuba)です。普段はサービス開発やバッチ処理開発をメインにやっておりますが、チームSREに参加してからはこれに加えて担当サービスのインフラ管理、そしてクラウド移行に携わっています。

今回はそのクラウド移行の話そのものではないのですが、それと必ず絡んでくるインフラ設定に関してです。

アクセス元IPアドレスを知りたい

Webアプリケーションがアクセス元IPアドレスを知りたいシーンというのは、大まかに二つかと思います。ログ記録用と、アクセス制限ですね。どちらもアプリケーションそのものではなく手前のWebサーバの責務のようにも思えますが、そうとも言い切れません。動作ログ、特に異常リクエストをはじいた記録なんかにセットでIPアドレスを付けたいとなるとアプリケーション要件ですし、アクセス制限についてもマルチテナントサービスであってテナントごとの許可IPアドレスがアプリケーションデータであるというケースがありえます。

そのとき、アプリケーションコードで例えばJavaなら HttpServletRequest.getRemoteAddr() でソースIPは得られますがそれってアクセス元クライアントのIPアドレスなんでしたっけという問題があります。先ほど「手前のWebサーバ」と書いたとおり、クライアントはアプリケーションサーバに直接アクセスするわけではなく下図の例のようにリバースプロキシのWebサーバとロードバランサ、それにWAFなどが入った複数段のステップで到達してくるのが一般的ですから。

f:id:Sampo:20210203155247p:plain

単純に HttpServletRequest.getRemoteAddr() をみると、一段階前のWebサーバのIPアドレスが常に取れてしまうということが起きるわけです。アプリケーションサーバにとってだけではなくWebサーバにとっても同様ですね。Webアクセスログには必ずアクセス元IPアドレスが入りますが、単純に記録させると一段階前の中継サーバ、上記の図だとロードバランサのIPアドレスばかりが記録されるということになりかねません。

もちろんこれから書いていく仕組みによってちゃんと取得できるようになりますよというのがこの話なのですが、それがどうしてサーバのクラウド移行にあたって絡んで来ると言っているのか。

それは、構成変更によって取得できていたアクセス元IPアドレスが取得できなくなったという事故がたやすく起こりえる仕組みだからです。ものによってはやはり大事故ですよ、IP制限目的で使っていた場合だと急に誰も使えなくなったり急にフルオープンになったりし、しかも社内からはいつも通りアクセスできてしまっていてトラブル発生に気付きさえしないなどということになりかねません。クラウド移行は大抵一発ガチャンで済むというわけにはいかずシステムを構成する複数のサーバやマイクロサービス、ミドルウェアなんかを何ステップかの構成変更にわけつつ進めていくのが通例と思いますが、構成変更を繰り返す中で思わぬ落とし穴となることがある仕組みだというわけです。

さてでは実際にアクセスしてきているクライアントのIPアドレスをどう認識するか、これはご存じの方も多いと思われますが X-Forwarded-For ヘッダを使うことが多いです。

X-Forwarded-For ヘッダ

X-Forwarded-For - HTTP | MDN

HTTPリクエストヘッダの一種で、X-で始まることからわかるとおり標準規格ではありませんが広く使われている取り決めです。本来のクライアントのIPアドレスを伝達することが目的。

リバースプロキシやロードバランサのようなWebリクエストを中継するサーバが、中継の際に次のようなルールでリクエストヘッダを付加することとしておきましょう:

  • もし受け取ったリクエストに X-Forwarded-For ヘッダがなかったならば、中継リクエストには X-Forwarded-For ヘッダを加え、その内容はソースIPアドレスとする。
  • もし受け取ったリクエストに X-Forwarded-For ヘッダがあったならば、中継リクエストでは X-Forwarded-For ヘッダの末尾にカンマ区切りでソースIPアドレスを書き加える。

すると、中継を受けた側のホストではそこまでの各中継者からみたソースIPアドレスが遠い順に X-Forwarded-For に並んでいることになります。クライアントIPアドレスは最も先頭(左側)に来ることでしょう。

本当にそれだけか? クライアントが自分のIPアドレスを偽装しようと X-Forwarded-For に注入した任意のIPアドレスまで混じっている可能性はないか? というとまったくその通りで、その可能性があるので「最初のものがクライアントIPアドレス」と解釈してしまうわけにはいきません。そのような解釈ロジックを書いてしまうとこれは脆弱性になります。クライアントが任意のソースIPを偽装できてしまいますからね。

偽装に影響されない正しい読み方があり、次のようになります。

  1. まずシステムを構成するすべての中継ホストのIPアドレスを列挙する。これらを「信頼されたIPアドレス」と呼ぶことにする。
  2. X-Forwarded-For、それに続けてソースIPを並べたIPアドレス列に、信頼されていないIPアドレスが含まれるなら信頼されていないもののうち最も末尾のものがクライアントIPアドレスである。
  3. そうでなければ、先頭のものがクライアントIPアドレスである。

信頼されたIPアドレスの列挙というのがキモになります。これがないことには正しい解釈ができないというわけ。

クラウド移行に限りませんが構成変更の際には信頼されたIPアドレスの増減というのが発生し得ますから、これの更新が漏れていると最初に書きましたとおりクライアントIPアドレスの取得が急におかしくなるということが発生するわけです。

実装

上記の中継のルールと解釈のルールを、Apacheとnginxではそれぞれ次のようなディレクティブで設定します。

Apache

# X-Forwarded-For を付加する中継ルールをオンにする。ただし明示的に指定しなくてもデフォルトでOn
ProxyAddHeaders On

# X-Forwarded-For を解釈せよとの指示
RemoteIPHeader X-Forwarded-For

# 信頼されたIPアドレスの指定(複数可)
RemoteIPTrustedProxy x.x.x.x y.y.y.y

nginx

# X-Forwarded-For を付加する中継ルールを記述
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

# X-Forwarded-For を解釈せよとの指示
real_ip_header X-Forwarded-For;
real_ip_recursive on;

# 信頼されたIPアドレスの指定(複数可)
set_real_ip_from x.x.x.x;
set_real_ip_from y.y.y.y;

アプリケーションにおいて

アプリケーションコード中では、なるべくX-Forwarded-Forを自力で解釈するコードを書くことは避けたいです。前節で書いたような解釈ルールを間違いなく実装できているかどうかの確認は難しいですし、もし誤りがあり脆弱性を作り込んでいても一見正しく動くしテストも引っかからないということになりがちだからです。

アプリケーションコードで書かないというのはどういうことかというと、例えばJava Tomcat開発であればAJPがremote_addrを伝達してくれます。上記解釈設定を入れたWebサーバをフロントに立てアプリケーションサーバとはAJPでつなぐことで、アプリケーションコード中では無邪気に getRemoteAddr() するだけでクライアントIPアドレスが取得できるようになるわけです。私は、インフラ構成に起因する複雑さをなるべくインフラ設定側に押し出すことでアプリケーションに不必要な複雑さを持ち込ませない形にするのがよいと考えます。

間違いは非常に多い

X-Forwarded-Forの取り扱いをアプリケーションコード中に書いてしまうケースは実際には多いと思います。ただ、正しい実装になっていないことも多く、やはり「最初のものを取る」だけの処理としてしまいがちです。ネットで出てくるのは多くがこのロジックですしね。

私も、これはもうチームSREとしての立場とはまったく関係なくなのですが意識するようになると自分の周囲でも色々見つかるものです。それで西に「最初のものを取る」コードを見つければ修正チケットを立て、東に「X-Forwarded-ForだとソースIP偽装が心配だよね」という議論があればプロキシ設定次第で回避可能ですよと伝えに行き、としているうちに行った先で「X-Forwarded-For警察」の名を頂戴するに至りました。光栄なことです。

We are Hiring!

エムスリーでは、コアSRE/チームSREを募集中です!

思えば私がチームSREに誘われたのはインフラ知識があるからではなく、いやいやむしろインフラは大の苦手なのですが自分の担当プロダクトの面倒見切るためにはインフラをSREチームに丸投げでは無理だ、これは自分で手を動かして把握するしかないとなってAnsibleを書いてはレビュー依頼投げてとしているうちになんかコイツやる気あるんじゃないのとチーム内で声をかけてもらった感じででした。

ミッション達成の最終責任は自分だ、担当するからにはどうやってでも達成してやるぜって強い思いをお持ちの方、ぜひぜひ一緒にお仕事したいです! カジュアル面談でもテックトークでも、まずはお話しなどいたしませんか。

jobs.m3.com