X-Forwarded-For の正しい取り扱い方とCloudFrontを通したときのクライアントIPの取得方法

X-Forwarded-For ってなに?

プロキシやロードバランサーを通してサーバーにアクセスすると、サーバーから見たクライアントIPはプロキシのものになってしまいます。それを解決するために、プロキシで X-Forwarded-For HTTP Header にクライアントの Remote IP を記録して、サーバーではその値を見ることでクライアントIPを取得できるようになります。

f:id:mrk21:20200806213221p:plain

X-Forwarded-For からクライアントIPを取得する

X-Forwarded-For HTTP Header は以下のようなフォーマットになっています。

X-Forwarded-For: <client>, <proxy1>, ...

そのためクライアントIPを得るためには X-Forwarded-For の先頭のIPを参照すればよさそうです。

しかし、この方法には問題があります。というのもプロキシでは X-Forwarded-For が存在していたのなら末尾に接続元のIPを追加するので、悪意のあるクライアントが X-Forwarded-For に任意のIPを指定することによってアクセス元IPを偽装することが可能になります(IPスプーフィング)。たとえば、サーバー側で X-Forwarded-For の値を元にIP制限をかけていた場合に突破される可能性があります。

f:id:mrk21:20200806213245p:plain

X-Forwarded-For からクライアントIPをセキュアに取得する

ではどうするのでしょうか。たとえば Rails では信頼できるプロキシのIPを事前に登録しておき、X-Forwarded-For のIPリストからその信頼できるプロキシのIPを除去し、末尾のIPをクライアントIPとして採用するというものがあります。

たとえば、サーバー到達時点の X-Forwarded-For の値が以下のようなものであったとします。

X-Forwarded-For: <偽装されたIP> <クライアントの真のIP>, <プロキシ1のIP>, <プロキシ2のIP>

そして、信頼できるプロキシのIPリストには以下が登録されているとします。

信頼できるプロキシのIPリスト := [プロキシ1のIP, プロキシ2のIP]

このような条件下では次の動作をします。まず、X-Forwarded-For から信頼できるプロキシのIPリストに登録しているIPを除去します。

X-Forwarded-For: <偽装されたIP> <クライアントの真のIP>

そして、その末尾のIPを取得します。これはクライアントの真のIPとなるので、偽装されたIPを取得することなく、悪意のあるクライアントからの攻撃を防げます。

f:id:mrk21:20200806215512p:plain

Railsでの詳細

ActionDispatch::Request#remote_ip では以下のようにして、信頼できるIPアドレスX-Forwarded-For から取得します。

module ActionDispatch
  class RemoteIp
    ...
    def call(env)
      req = ActionDispatch::Request.new env
      req.remote_ip = GetIp.new(req, check_ip, proxies)
      @app.call(req.env)
    end
    ...
    class GetIp
      ...
      def calculate_ip
        # Set by the Rack web server, this is a single value.
        remote_addr = ips_from(@req.remote_addr).last

        # Could be a CSV list and/or repeated headers that were concatenated.
        client_ips    = ips_from(@req.client_ip).reverse
        forwarded_ips = ips_from(@req.x_forwarded_for).reverse

        # +Client-Ip+ and +X-Forwarded-For+ should not, generally, both be set.
        # If they are both set, it means that either:
        #
        # 1) This request passed through two proxies with incompatible IP header
        #    conventions.
        # 2) The client passed one of +Client-Ip+ or +X-Forwarded-For+
        #    (whichever the proxy servers weren't using) themselves.
        #
        # Either way, there is no way for us to determine which header is the
        # right one after the fact. Since we have no idea, if we are concerned
        # about IP spoofing we need to give up and explode. (If you're not
        # concerned about IP spoofing you can turn the +ip_spoofing_check+
        # option off.)
        should_check_ip = @check_ip && client_ips.last && forwarded_ips.last
        if should_check_ip && !forwarded_ips.include?(client_ips.last)
          # We don't know which came from the proxy, and which from the user
          raise IpSpoofAttackError, "IP spoofing attack?! " \
            "HTTP_CLIENT_IP=#{@req.client_ip.inspect} " \
            "HTTP_X_FORWARDED_FOR=#{@req.x_forwarded_for.inspect}"
        end

        # We assume these things about the IP headers:
        #
        #   - X-Forwarded-For will be a list of IPs, one per proxy, or blank
        #   - Client-Ip is propagated from the outermost proxy, or is blank
        #   - REMOTE_ADDR will be the IP that made the request to Rack
        ips = [forwarded_ips, client_ips, remote_addr].flatten.compact

        # If every single IP option is in the trusted list, just return REMOTE_ADDR
        filter_proxies(ips).first || remote_addr
      end
      ...
      def filter_proxies(ips) # :doc:
        ips.reject do |ip|
          @proxies.any? { |proxy| proxy === ip }
        end
      end
    end
    ...
  end
end

rails/remote_ip.rb at c81af6ae723ccfcd601032167d7b7f57c5449c33 · rails/rails

そのため、config.action_dispatch.trusted_proxies に前段にあるプロキシ/ロードバランサーのIPを設定します。

config/initializers/trusted_proxies.rb:

Rails.application.configure do
  # Proxy 1: 56.103.84.14
  # Proxy 2: 56.103.84.15
  config.action_dispatch.trusted_proxies = %w{
    56.103.84.14
    56.103.84.15
  }.map { |proxy| IPAddr.new(proxy) }
end

このようにすることで、X-Forwarded-For が以下のようになっていた場合にクライアントのIPが取得できます。

X-Forwarded-For: 103.10.21.3, 56.103.84.14            # Proxy 1、Proxy 2を経由した場合。103.22.34.5 がクライアントIP
X-Forwarded-For: 17.20.1.3, 103.10.21.3, 56.103.84.14 # Proxy 1、Proxy 2を経由した場合。17.20.1.3 は偽装されたIP
class ApplicationController < ActionController::Base
  before_action :check_ip

  def check_ip
    if request.remote_ip == '17.20.1.3'
      ...
    end
  end
end

CloudFrontを使用した場合に X-Forwarded-For からクライアントIPをセキュアに取得する

上記で述べた方法でクライアントIPをセキュアに取得できますが、 CloudFront - ALB - Server という構成の場合は少々やっかいです。というのも CloudFront は世界中に数十ものエッジサーバーがあり、かつIPが変わり得るので、上記で述べたように CloudFront のIPを事前に信頼できるプロキシに登録するのは困難であるからです。

しかし、AWSAWSの各種サービスの取り得るIPリストを取得できるAPI( https://ip-ranges.amazonaws.com/ip-ranges.json )が存在し、これをもとに信頼できるIPリストに登録できます。Rails で一番簡単なのは config/initializer などで、Rails起動時に https://ip-ranges.amazonaws.com/ip-ranges.json APIにアクセスし、信頼できるIPリストに登録するというものです。

config/initializers/trusted_proxies.rb:

Rails.application.configure do
  ip_ranges_res = Faraday.get('https://ip-ranges.amazonaws.com/ip-ranges.json')
  ip_ranges = JSON.parse(ip_ranges_res.body)
  cloudfront_ips = ip_ranges['prefixes'].select { |v| v['service'] == 'CLOUDFRONT' }.map { |v| IPAddr.new(v['ip_prefix']) } +
                   ip_ranges['ipv6_prefixes'].select { |v| v['service'] == 'CLOUDFRONT' }.map { |v| IPAddr.new(v['ipv6_prefix']) }

  config.action_dispatch.trusted_proxies = cloudfront_ips
end

f:id:mrk21:20200806223957p:plain

まとめ

X-Forwarded-For を使うことで、クライアント・サーバー間にプロキシやロードバランサが存在してもクライアントのIPを取得することができます。 しかし、適切に扱わないと脆弱性が発生するので注意する必要があります。

参考