2022/7/24: Plack::Middleware::ReverseProxyが、X-Forwarded-For
をサポートしていますよ、と教えていただいたので、記載を修正しました。
@karupanerura++
Scalatra 3.0.0-M1がリリースされた。Scala3対応だ、めでたい。
最近Scalatraへあまり貢献できていなかったので、いくつかのコンパイル時の警告を消すPRを出した。コードを見直す中で、ふとX-Forwarded-For
ヘッダからクライアントのIPアドレスを取得する箇所が目にとまった。
/** * The remote address the client is connected from. * This takes the load balancing header X-Forwarded-For into account * @return the client ip address */ def remoteAddress: String = header("X-FORWARDED-FOR").flatMap(_.split(",").map(_.trim).filterNot(StringUtils.isBlank).headOption).getOrElse(r.getRemoteAddr)
確かX-
で始まるヘッダは非推奨になっていて、別のヘッダが標準化されているんじゃなかったっけ?と、気になって調べてみたので、その覚え書き。
X-Forwarded-For
ヘッダとは?
X-Forwarded-For
ヘッダはX-
で始まることから分かる通り、何かの規格で定義されている訳ではなく、事実上標準的に使われているヘッダの一つ。
MDNに解説が書かれているので、詳しくはリンク先を読んだ方がいいけど、先頭の解説を引用すると、以下のように書かれている。
X-Forwarded-For (XFF) ヘッダーは、 HTTP プロキシーサーバーを通過してウェブサーバーへ接続したクライアントの、送信元 IP アドレスを特定するために事実上の標準となっているヘッダーです。
類似のヘッダに、X-Forwarded-Host
や、X-Forwarded-Proto
が有るが、あまり使われている例は見たことがない。元はSquid
というプロキシソフトウェアが始めたものとのこと。
途中でプロキシサーバによるIPアドレスの変換が行われると元のIPアドレスが付加されていく。複数のプロキシを経由した場合は、後ろに足されていくため、複数のIPアドレスが記録される可能性がある(ちなみに、Scalatraでは複数のIPアドレスが記録されているパターンを上手く扱えず、ちょっと前に修正された)。
通常はIPアドレスのみで、ポート番号は付かない実装がほとんどのようだが、RackのチケットにAzureのApplication Gateway
がX-Forwarded-For
ヘッダにポート番号付でIPアドレスを記録するので、そのポート番号を削除するコードがマージされている。
あと、後述するが、面倒なことに、IPv4とIPv6で記法が異なる。
Forwarded
ヘッダとは?
X-Forwarded-For
ヘッダは非標準ということで、RFC 7239 Forwarded HTTP Extension
で同様の機能を持つヘッダ「Forwarded
」が標準化されている。
しかし、ヘッダーの中で更にfor
と書かれている部分を抜き出す必要があるし、IPv6の場合は引用符と角括弧で囲む必要が有り、ちょいと面倒くさい。
X-Forwarded-For: 123.34.567.89 Forwarded: for=123.34.567.89 X-Forwarded-For: 192.0.2.43, "[2001:db8:cafe::17]" Forwarded: for=192.0.2.43, for="[2001:db8:cafe::17]"
そもそも実際に、使われているのか、よく分からない。
Rackではちゃんとサポートされているようだ。
def forwarded_for forwarded_priority.each do |type| case type when :forwarded if forwarded_for = get_http_forwarded(:for) return(forwarded_for.map! do |authority| split_authority(authority)[1] end) end when :x_forwarded if value = get_header(HTTP_X_FORWARDED_FOR) return(split_header(value).map do |authority| split_authority(wrap_ipv6(authority))[1] end) end end end
ScalatraにおけるX-Forwarded-For
ヘッダーの扱い
ScalatraはServletベースのフレームワークなので、アプリケーションコードからはgetRemoteAddr
メソッドを使ってクライアントのIPアドレスを取得する。
getRemoteAddr() クライアントまたはリクエストを送信した最後のプロキシのインターネットプロトコル(IP)アドレスを返します。
Servletが参考にしたCGI規格ではクライアントのIPアドレスは、REMOTE_ADDR
で取得するが、JavaではSnake Caseを使わないのでアンダースコアが省略されている。
Scalatraでは、remoteAddress
というメソッドがRequestオブジェクト
に用意されていて、これは先ほどのX-Forwarded-For
ヘッダーが設定されている場合、getRemoteAddr
の値ではなく、”おそらくクライアントのものと思われるIPアドレス"を取得して、設定している。
特にIPv6のことは考慮されていないし、後述する信頼されたIPアドレスのことも考慮されていない、最もシンプルな実装になっている。
では、どう取り扱うのが良いか?
通常は、クライアントのIPアドレスは、リクエストが途中で複数のプロキシを経由すると、その度に末尾に変換前のIPアドレスが付加されるため、X-Forwarded-For
ヘッダの先頭に書かれているIPアドレスと解釈される。
つまり、以下の例ではAの箇所に書かれているIPアドレスが最初にリクエストを送信したクライアントのIPアドレスと解釈される(2個目以降のB、Cは単に無視される)。
X-Forwarded-For: A, B, C
通常、インターネット経由などで送られてくるリクエストの場合、わざわざ送信元の情報を外部に開示することは無いため、実際には多段階のプロキシを経由していたとしてもX-Forwarded-For
ヘッダが付与されていることは考えづらい。
一方で、MDNのX-Forwarded-For
ヘッダの解説によると、わざと不正なX-Forwarded-For
ヘッダを付加してリクエストが送られてくる可能性を考慮すべきと書かれている。
クライアントから最も近い
X-Forwarded-For
の IP アドレスを選択する場合(信頼できない、かつ、セキュリティに関係の ない 目的で)は、左端から 有効 で プライベート/内部 ではない最初の IP アドレスを選択する必要があります。(なりすましの値は IP アドレスではない可能性があるため "有効" である必要があります。また、 "プライベート/内部アドレスではない" というのはクライアントが彼らの内部ネットワークのプロキシーを使用した可能性があり、その場合 プライベートネットワーク で IP アドレスが追加されている可能性があるためです。)信頼できる_ 最初の
X-Forwarded-For
クライアント IP アドレスを選択する場合、追加の設定が要求されます。これらは一般的に2つの方法があります:
- 信頼できるプロキシーの数: インターネットとサーバーの間のリバースプロキシーの数が設定されている。
X-Forwarded-For
IP リストは右端から1を引いて位置から検索されます。(例えば、リバースプロキシーが1つの場合は、そのプロキシーはクライアントの IP アドレスを追加するため右端の IP アドレスを使用するべきです。もし、リバースプロキシーが3つある場合、最後の2つの IP アドレスは内部のものになるでしょう。)- 信頼できるプロキシーのリスト: 信頼できるリバースプロキシーの IP リストもしくは IP の範囲が設定されている。
X-Forwarded-For
IP リストは右端から検索されますが、この時に信頼できるプロキシーリストのアドレスはスキップされます。信頼できるリストに一致しなかった最初のアドレスが目的のアドレスです。最初の信頼できる
X-Forwarded-For
IP アドレスは実際のクライアントコンピューターではなく信頼できない中間プロキシーのものかもしれません、しかし、それはセキュリティ用途のための唯一適した IP アドレスです。
少なくとも自分たちが管理するネットワークに接続される際に使ったIPアドレス自体は正しいので、自分たちが管理している(=信頼できる)プロキシの直前のIPアドレスを使っておけば不正に差し込まれたIPアドレスではないと判断できる、という考え方のようです。
そもそも不正なヘッダを送信してくる時点でIPアドレスを取得するよりも別にやることが有るような気もしますが...
PlackでのX-Forwarded-For
の対応と、実装
HTTPプロトコル関係の実装例を見たくなったらまずはPlackか、Rackの実装を見てしまうのですが、PlackではPlack::Middleware::ReverseProxy
を使うことでX-Forwarded-For
をサポートしています。
X-Forwarded-For
に記述されている最後のIPアドレスをREMOTE_ADDR
に設定してくれます。
# If we are running as a backend server, the user will always appear # as 127.0.0.1. Select the most recent upstream IP (last in the list) if ( $env->{'HTTP_X_FORWARDED_FOR'} ) { my ( $ip, ) = $env->{HTTP_X_FORWARDED_FOR} =~ /([^,\s]+)$/; $env->{REMOTE_ADDR} = $ip; }
また、Graham Barrさん作成のPlack::Middleware::XForwardedFor
を使うと、同じようにX-Forwarded-For
からクライアントのIPアドレスを取得できますが、信用できるプロキシのリストをサブネットマスク付で指定できるようになっている点が異なります。
if (@forward) { my $addr = $env->{REMOTE_ADDR}; if (my $trust = $self->trust) { # $self-trustは、Net::IPの配列リファレンスを持つ ADDR: { if (my $next = pop @forward) { foreach my $netmask (@$trust) { my $ip = Net::IP->new($addr) or redo ADDR; if ($netmask->overlaps($ip)) { $addr = $next; redo ADDR; } } } } } else { # trust everything, so use first in list $addr = shift @forward; } $env->{REMOTE_ADDR} = $addr; }
信頼できるプロキシのリストが与えられない時は、単にX-Forwarded-For
の先頭のIPアドレスをREMOTE_ADDR
に設定して終わりです。
信頼できるプロキシのリストが与えられた場合は、今度は逆にREMOTE_ADDR
→X-Forwarded-For
の末尾から先頭という順で、最初に信頼できるIPアドレスに該当しないアドレスを探索していって最初に見つかったアドレスをREMOTE_ADDR
を返すようになっています。
仮に、最初からREMOTE_ADDR
に信頼されたIPアドレスではないものが登録されていれば、X-Forwarded-For
の内容は一切無視されます。
なお、上記の引用では省きましたが、IPv4射影アドレスとしてのIPv6のIPアドレスはサポートされているようですが(それでも記述形式が合っていない気がする...)、通常のIPv6アドレスはサポートされていないようです。
Rackの実装
RackはデフォルトでX-Forwarded-For
をサポートしていて、信頼できるIPアドレスを取り除いて、最初に見つかったIPアドレスを、クライアントのIPアドレスと定義しています(Forwarded
ヘッダもサポートしていて、そちらを優先して使うようになっています)。
reject_trusted_ip_addresses
というメソッドで信頼できるIPアドレスを取り除いて、結果的に見つからない場合は、便宜的にX-Forwarded-For
の先頭をクライアントのIPアドレスとして取り扱う実装になっている。
def ip remote_addresses = split_header(get_header('REMOTE_ADDR')) external_addresses = reject_trusted_ip_addresses(remote_addresses) unless external_addresses.empty? return external_addresses.last end if (forwarded_for = self.forwarded_for) && !forwarded_for.empty? # The forwarded for addresses are ordered: client, proxy1, proxy2. # So we reject all the trusted addresses (proxy*) and return the # last client. Or if we trust everyone, we just return the first # address. return reject_trusted_ip_addresses(forwarded_for).last || forwarded_for.first end # If all the addresses are trusted, and we aren't forwarded, just return # the first remote address, which represents the source of the request. remote_addresses.first end def reject_trusted_ip_addresses(ip_addresses) ip_addresses.reject { |ip| trusted_proxy?(ip) } end
信頼できるIPアドレスは、最初からプライベートアドレスがすべて定義されていて、外から定義を与えることはできない。
trusted_proxies = Regexp.union( /\A127#{valid_ipv4_octet}{3}\z/, # localhost IPv4 range 127.x.x.x, per RFC-3330 /\A::1\z/, # localhost IPv6 ::1 /\Af[cd][0-9a-f]{2}(?::[0-9a-f]{0,4}){0,7}\z/i, # private IPv6 range fc00 .. fdff /\A10#{valid_ipv4_octet}{3}\z/, # private IPv4 range 10.x.x.x /\A172\.(1[6-9]|2[0-9]|3[01])#{valid_ipv4_octet}{2}\z/, # private IPv4 range 172.16.0.0 .. 172.31.255.255 /\A192\.168#{valid_ipv4_octet}{2}\z/, # private IPv4 range 192.168.x.x /\Alocalhost\z|\Aunix(\z|:)/i, # localhost hostname, and unix domain sockets )
全部プライベートアドレスの場合は、前述した通り、X-Forwarded-For
の先頭のIPアドレスが返される(よく見ると、localhost
という文字列が帰ってくる場合はも有るのか…)。
おわりに
ちなみにGo言語のnet/http
は、X-Forwarded-For
はサポートしていなかった。
全くサポートしていないか、単にX-Forwarded-For
の先頭を返すか、信頼できるIPアドレスを元に丁寧にチェックするか、リバースプロキシ的に一つ前のアドレスを返すか...その4種類の実装が有ることが分かりました。
Rackの実装が整理されていますね(ローカルIPアドレスだけで構成されたローカルネットワークの中で多段プロキシが有ってもちゃんと動作するし、不正なヘッダは読みこないし)。
と、散々調べたものの、さまざまなネットワーク構成が取り得る現代においてWebアプリケーションのレイヤーでクライアントのIPアドレスを参照してアクセス制御するのか?とか、httpsだとそもそも途中の経路でヘッダを差し込めないのでは?とか、Forwardedヘッダって使われているの???とか考え始めると、そこまでフレームワーク側で頑張ることなのか?という気持ちになったので、特にScalatraのコードの見直しはしませんでした。