CloudFront + ALB の背後で訪問者 IP を取得する:PHP で X-Forwarded-For を解析してはいけない理由

mod_remoteip 処理後の X-Forwarded-For を PHP で再解析すると偽造値を拾う。CloudFront-Viewer-Address を使う修正と前提。

WordPress を CloudFront と ALB の背後に置くと、PHP から見える REMOTE_ADDR は訪問者のアドレスではありません。よくある対処は、wp-config.php で X-Forwarded-For を自前で解析し、「後ろから何番目か」のエントリを取るというものです。本サイトでも以前そう書いていましたが、その結果、訪問者が自分の IP を好きに指定できる状態になっていました。原因は、公式 WordPress イメージの Apache が mod_remoteip でこのヘッダーをすでに処理し、書き換えていたことです。本記事では 1 つのリクエストが実際にたどる経路に沿ってこの問題を分解し、CloudFront-Viewer-Address に切り替える修正方法と、それが成り立つ前提を説明します。

リクエスト経路上の X-Forwarded-For

訪問者の実際のアドレスが 198.51.100.10 で、訪問者がリクエストに偽のヘッダーを自分で付けたとします。以下のアドレスはいずれもドキュメント用のサンプルアドレスです。

GET / HTTP/1.1
Host: www.example.com
X-Forwarded-For: 203.0.113.66        ← 訪問者が自分で書いたもの

各段階は順に次のように処理します。

段階処理処理後の X-Forwarded-For
CloudFrontTCP 接続で見えた訪問者のアドレスを末尾に追加203.0.113.66, 198.51.100.10
ALB(デフォルトの append モード)自身に接続してきたクライアント、つまり CloudFront エッジノードのアドレスを末尾に追加203.0.113.66, 198.51.100.10, 192.0.2.199
Apache mod_remoteip次節を参照203.0.113.66, 198.51.100.10

最初の 2 段階は CloudFront と ALB のドキュメントに明記された動作です。この 2 段階だけを見れば、「後ろから 2 番目のエントリが訪問者」で正しいように思えます。最後がエッジノード、後ろから 2 番目が CloudFront の追加した訪問者のアドレスで、訪問者はこれを偽造できないからです。本サイトも当初はそのように書いていました。

// 問題のある書き方:X-Forwarded-For をもう一度解析し、後ろから 2 番目を取る
$xff = array_map('trim', explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']));
$_SERVER['REMOTE_ADDR'] = $xff[max(0, count($xff) - 2)];

mod_remoteip がすでに先に処理している

公式の wordpress:*-apache イメージでは mod_remoteip が有効になっており、設定は次のとおりです。

RemoteIPHeader X-Forwarded-For
RemoteIPInternalProxy 10.0.0.0/8
RemoteIPInternalProxy 172.16.0.0/12
RemoteIPInternalProxy 192.168.0.0/16
RemoteIPInternalProxy 169.254.0.0/16
RemoteIPInternalProxy 127.0.0.0/8

Apache のドキュメントによると、mod_remoteip はこのヘッダーを右から左へ処理します。信頼されていないアドレスに当たるとそこで止まり、それをクライアントのアドレスとし、ヘッダーを「まだ確認されていない残りのアドレス」に書き換えます。すべてが信頼できる場合は、ヘッダー自体を削除します。

上の例に当てはめます。Apache に接続しているのは ALB で、そのアドレスは VPC のプライベートアドレス範囲にあり、信頼された内部プロキシに当たります。ALB が渡した一番右のエントリ 192.0.2.199 はエッジノードのパブリックアドレスで、信頼リストにないため、処理はここで終わります。結果は次のとおりです。

  • Apache と PHP の REMOTE_ADDR は 192.0.2.199、つまり CloudFront のエッジノードになります。
  • PHP に渡される X-Forwarded-For はすでに 203.0.113.66, 198.51.100.10 に書き換えられており、一番右の項目は「消費」されています。

この状態で PHP がさらに後ろから 2 番目を取ると、得られるのは 203.0.113.66、まさに訪問者が偽造した値です。訪問者がこのヘッダーを付けていなければ、書き換え後は 198.51.100.10 の 1 項目だけが残り、max(0, count - 2) はインデックス 0 を取るので、結果は正しくなります。そのため通常のテストはすべて通り、意図的に偽造したときだけ誤ります。

以前、CloudFront がなく ALB だけだった頃は、コードは最後の項目を取っていましたが、問題は同じです。mod_remoteip が ALB の追加した訪問者アドレスをすでに消費しており、残った最後の項目はやはり訪問者が自分で書いたものです。結論として、Apache の段階ですでに mod_remoteip が処理している場合、PHP から見える X-Forwarded-For には訪問者が制御できる部分しか残っていないので、再度解析すべきではありません。

修正方法:CloudFront-Viewer-Address を使う

CloudFront は、オリジンへのリクエストに CloudFront-Viewer-Address ヘッダーを追加できます。値は訪問者の IP と送信元ポートで、例えば 198.51.100.10:46532 のようになります。このヘッダーは自動では付かず、オリジンリクエストポリシーで含める必要があります。マネージドポリシー AllViewerAndCloudFrontHeaders-2022-06 には含まれており、カスタムのオリジンリクエストポリシーで個別に追加することもできます。このヘッダーはオリジンリクエストポリシーにしか入れられず、キャッシュポリシーには入れられません。

wp-config.php で(本サイトでは公式イメージの WORDPRESS_CONFIG_EXTRA で注入しています)、末尾のポートを取り除くだけです。

if (!empty($_SERVER['HTTP_CLOUDFRONT_VIEWER_ADDRESS'])) {
    $_SERVER['REMOTE_ADDR'] = preg_replace('/:\d+$/', '', $_SERVER['HTTP_CLOUDFRONT_VIEWER_ADDRESS']);
}

正規表現は最後の「コロン + 数字」だけを取り除くので、IPv6 アドレス自体に含まれるコロンは影響を受けません。このコードは wp-config.php の中でプラグインの読み込みより前に実行されるため、プラグインのレート制限、ログ、コメント記録が受け取るのはすべて書き換え後のアドレスです。

このヘッダーを信頼できる理由

ヘッダー名を変えるだけでは安全になりません。次の 2 つの前提が同時に成り立つ必要があります。

  1. 値は CloudFront が生成する。AWS のドキュメントには、このヘッダーには CloudFront から見た訪問者のアドレスとポートが入ると書かれています。本サイトの README には、訪問者が自分で付けた同名のヘッダーは CloudFront によって上書きされるという結論が記録されています。ただし、AWS のドキュメントには「上書きする」と明記した文は見つかりませんでした。本番投入前に、リクエストヘッダーをそのまま表示する一時的なページを使って、自分の環境で一度確認することをお勧めします。
  2. オリジンは CloudFront からのリクエストだけを受け付ける。そうでなければ、攻撃者は CloudFront を迂回して ALB に直接接続し、CloudFront-Viewer-Address を自分で書けます。本サイトの ALB ルールは Host の一致に加えて、CloudFront だけが付けるシークレットヘッダーを要求しており、このヘッダーのないリクエストはリスナーのデフォルトの 404 になります。

上の PHP のコードは、2 つ目の前提が成り立つ環境でのみ有効にしてください。本サイトの CDK コードでは、CloudFront ディストリビューションが設定されている場合にのみこのコードが生成されます。

確認方法

偽のヘッダーを付けてサイトにリクエストし、WordPress が記録したアドレスを確認します。例えば、テスト記事にコメントを投稿し、管理画面でコメント投稿者の IP を見ます。

curl -s -H 'X-Forwarded-For: 203.0.113.66' https://www.example.com/ -o /dev/null

修正前は、管理画面に 203.0.113.66 と表示されます。修正後は自分の送信元(出口)アドレスになるはずです。あわせて、CloudFront を迂回したオリジンへの直接接続が拒否されることも確認してください。正しい Host を付け、シークレットヘッダーを付けずに ALB にリクエストすると、WordPress のページではなくデフォルトアクションのレスポンスが返るはずです。

制約と注意点

  • このコードが変更するのは PHP の $_SERVER['REMOTE_ADDR'] だけです。Apache のアクセスログには、引き続き mod_remoteip が導いたエッジノードのアドレスが記録されます。
  • この方式は CloudFront を前提にしています。将来、手前にもう 1 層プロキシを追加したり CDN を変更したりする場合は、経路全体を改めて整理する必要があります。
  • 別の方法として、mod_remoteip に CloudFront のオリジン向けアドレス範囲を信頼させ、Apache が直接訪問者のアドレスを導く方法もあります。ただしそのアドレスリストを継続的に保守する必要があるため、本サイトでは採用していません。
  • ALB の routing.http.xff_header_processing.mode は append、preserve、remove のいずれかに設定できます。共有 ALB でこれを変えるとすべてのバックエンドに影響するため、本サイトではデフォルトのままにしています。

リンク