Magnolia Tech

いつもコードのことばかり考えている人のために。

`X-Forwarded-For`ヘッダからクライアントのIPアドレスを取得する

2022/7/24: Plack::Middleware::ReverseProxyが、X-Forwarded-Forをサポートしていますよ、と教えていただいたので、記載を修正しました。 @karupanerura++


Scalatra 3.0.0-M1がリリースされた。Scala3対応だ、めでたい。

github.com

最近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 アドレスを特定するために事実上の標準となっているヘッダーです。

developer.mozilla.org

類似のヘッダに、X-Forwarded-Hostや、X-Forwarded-Protoが有るが、あまり使われている例は見たことがない。元はSquidというプロキシソフトウェアが始めたものとのこと。

途中でプロキシサーバによるIPアドレスの変換が行われると元のIPアドレスが付加されていく。複数のプロキシを経由した場合は、後ろに足されていくため、複数のIPアドレスが記録される可能性がある(ちなみに、Scalatraでは複数のIPアドレスが記録されているパターンを上手く扱えず、ちょっと前に修正された)。

通常はIPアドレスのみで、ポート番号は付かない実装がほとんどのようだが、RackのチケットにAzureのApplication GatewayX-Forwarded-Forヘッダにポート番号付でIPアドレスを記録するので、そのポート番号を削除するコードがマージされている。

github.com

あと、後述するが、面倒なことに、IPv4IPv6で記法が異なる。

Forwardedヘッダとは?

X-Forwarded-Forヘッダは非標準ということで、RFC 7239 Forwarded HTTP Extensionで同様の機能を持つヘッダ「Forwarded」が標準化されている。

developer.mozilla.org

しかし、ヘッダーの中で更に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

クライアントから最も近い 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をサポートしています。

metacpan.org

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_ADDRX-Forwarded-Forの末尾から先頭という順で、最初に信頼できるIPアドレスに該当しないアドレスを探索していって最初に見つかったアドレスをREMOTE_ADDRを返すようになっています。

仮に、最初からREMOTE_ADDRに信頼されたIPアドレスではないものが登録されていれば、X-Forwarded-Forの内容は一切無視されます。

なお、上記の引用では省きましたが、IPv4射影アドレスとしてのIPv6IPアドレスはサポートされているようですが(それでも記述形式が合っていない気がする...)、通常の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のコードの見直しはしませんでした。