CloudFront → ELB → EC2(Rails App)な構成でTurnoutのallowed_ipsをいい感じに機能させる

株式会社LITALICOでWebエンジニアをやっています, @Takuan_Oishiiです。
この記事は『LITALICO Advent Calendar 2017』4日目の記事です.

はじめに

Turnout, 気軽にメンテ画面に移行できてめっちゃ便利ですよね. パスやアクセス元IPを指定してメンテ画面を出さないようにしたりできるので結構重宝します.
ただし, タイトルの通り「ユーザーからのリクエストをまずCloudFrontで受けてオリジンであるELBに転送してELBの下にRailsのアプリがいる」という構成を取っている場合, アクセス元IPを指定してメンテ画面を表示しないようにするという機能(allowed_ips)がうまく動きません.

この記事では, Turnoutのallowed_ipsをいい感じに機能させるモンキーパッチを紹介します.
ついでにTurnout, Rack::Request, ActionDispatch::Requestの中身がどうなっているのかについて軽く解説します.

環境: ruby 2.4.2 rails 5.1.4, turnout 2.4.1

結論

以下のモンキーパッチなファイルをconfig/initializers以下に配置する.

turnout.rb
require 'ipaddr'

module Turnout
  class Request
    private

    def ip_allowed?(allowed_ips)
      begin
        ip = IPAddr.new(rack_request.first_client_ip.to_s)
      rescue ArgumentError
        return false
      end

      allowed_ips.any? do |allowed_ip|
        IPAddr.new(allowed_ip).include? ip
      end
    end
  end
end
rack_first_client_ip
module Rack
  class Request
    module Helpers
      def first_client_ip
        split_ip_addresses(get_header('HTTP_X_FORWARDED_FOR')).first
      end
    end
  end
end

解説

X-Forwarded-Forの値

CloudFront → ELB → EC2(Rails App)という構成にしている場合, EC2に到達するリクエストのX-Forwarded-Forヘッダーは アクセス元のclient-ip, cloudfront-ip という値になります.
これは, CloudFrontもELBも以下のような挙動でリクエストを転送するためです.
カスタムオリジンの場合のリクエストとレスポンスの動作 - Amazon CloudFront

ビューワーがリクエストを CloudFront に送信し、X-Forwarded-For リクエストヘッダーを含めない場合、CloudFront は TCP 接続からビューワーの IP アドレスを取得して、IP アドレスが含まれた X-Forwarded-For ヘッダーを追加し、リクエストをオリジンに転送します。たとえば、CloudFront が TCP 接続から IP アドレス 192.0.2.2 を取得する場合、以下のヘッダーをオリジンに転送します。

X-Forwarded-For: 192.0.2.2

HTTP ヘッダーおよび Classic Load Balancer - Elastic Load Balancing

クライアントからのリクエストに既に X-Forwarded-For ヘッダーが含まれている場合、Elastic Load Balancing はヘッダー値の末尾にクライアントの IP アドレスを追加します。この場合、リストの最後の IP アドレスが、クライアントの IP アドレスです。たとえば、次のヘッダーには、クライアントによって追加された 2 つの IP アドレス (信頼できない可能性があります) と、Elastic Load Balancing によって追加されたクライアント IP アドレスがあります。

X-Forwarded-For: ip-address-1, ip-address-2, client-ip-address

Turnoutのallowed_ip指定がうまくいかない理由

Turnoutはメンテナンス画面を表示するかどうかを, allowed?メソッドによって判定しています.
さらに, ip_allowed? メソッドはアクセス元IPアドレスが, 設定ファイル等に記述されたallowed_ipsに含まれているかどうかを判定しています.
ここで言うアクセス元IPアドレスは, rack_request.ip.to_sによって取得されています.

https://github.com/biola/turnout/blob/master/lib/turnout/request.rb

turnout/lib/turnout/request.rb
require 'ipaddr'

module Turnout
  class Request
    #(中略)

    def allowed?(settings)
      path_allowed?(settings.allowed_paths) || ip_allowed?(settings.allowed_ips)
    end

    private

    attr_reader :rack_request

    #(中略)

    def ip_allowed?(allowed_ips)
      begin
        ip = IPAddr.new(rack_request.ip.to_s)
      rescue ArgumentError
        return false
      end

      allowed_ips.any? do |allowed_ip|
        IPAddr.new(allowed_ip).include? ip
      end
    end
  end
end

rack_request.ipは, rack/lib/rack/request.rbのipメソッドです.
ポイントはX_FORWARDED_FORの最後の値を返り値としていることです.

https://github.com/rack/rack/blob/master/lib/rack/request.rb

rack/lib/rack/request.rb
require 'rack/utils'
require 'rack/media_type'

module Rack
  # Rack::Request provides a convenient interface to a Rack
  # environment.  It is stateless, the environment +env+ passed to the
  # constructor will be directly modified.
  #
  #   req = Rack::Request.new(env)
  #   req.post?
  #   req.params["data"]

  class Request
  #(中略)

    module Helpers
    #(中略)

      def ip
        remote_addrs = split_ip_addresses(get_header('REMOTE_ADDR'))
        remote_addrs = reject_trusted_ip_addresses(remote_addrs)

        return remote_addrs.first if remote_addrs.any?

        forwarded_ips = split_ip_addresses(get_header('HTTP_X_FORWARDED_FOR'))

        return reject_trusted_ip_addresses(forwarded_ips).last || get_header("REMOTE_ADDR")
      end

    #(中略)
    end
    include Env
    include Helpers
  end
end

今回想定する構成では, X-Forwarded-Forが前述した状態になるため, rack_request.ipはCloudFrontのIPアドレスを返すことになります.
当然ですが, Turnoutのallowed_ipsで判定したいのはCloudFrontのIPアドレスではありません.
一番最初にリクエストを送った普通のユーザーのIPアドレスで判定を行いたくて, allowed_ipsにも特別扱いしたい普通のユーザーのIPアドレスを設定しているはずです.

以上の理由で, Turnoutのallowed_ipsの機能は今回想定する構成では期待した動作になりません.

解決策

ip_allowed?メソッドがrack_request.ipではなく rack_request.first_client_ipを使ってallowed_ipsとの比較を行うようにオーバーライドしてしまいました.

turnout.rb(再掲)
require 'ipaddr'

module Turnout
  class Request
    private

    def ip_allowed?(allowed_ips)
      begin
        ip = IPAddr.new(rack_request.first_client_ip.to_s)
      rescue ArgumentError
        return false
      end

      allowed_ips.any? do |allowed_ip|
        IPAddr.new(allowed_ip).include? ip
      end
    end
  end
end

first_client_ipなんてメソッドはRackには存在しませんので, 自分で作ってしまいます.
ipメソッドとは逆で, X_FORWARDED_FORの先頭の値を返すようにしています.

rack_first_client_ip(再掲)
module Rack
  class Request
    module Helpers
      def first_client_ip
        split_ip_addresses(get_header('HTTP_X_FORWARDED_FOR')).first
      end
    end
  end
end

これでめでたくallowed_ipsに指定したIPアドレスからのリクエストでメンテナンス画面を表示しないようにできました🍣

おまけ

Railsのコントローラ等ではおもむろにrequest.ipとかrequest.remote_ipのメソッドでIPアドレスを取得することができますが, これは実はRack::Requestのipメソッドではありません.
これらはActionDispatch::Requestのメソッドです.

さらに, 少々めんどくさいソースコードを追っていくと, remote_ipメソッドの返り値がActionDispatch::RemoteIp::GetIpcalculate_ipメソッドによって決定されていることがわかります.
https://github.com/rails/rails/blob/master/actionpack/lib/action_dispatch/middleware/remote_ip.rb

結局このメソッドも最終的にはX_FORWARDED_FORの末尾の値を返すようになっています.
そもそもTurnoutはActionDispatchとは一切関係ないので, 「ip_allowed?メソッドがrequest.remote_ip呼べばいいじゃん!」 というのは通用しませんし, remote_ipを呼べたとしてもipと同じ結果が返ってくるだけです.

さらにもうちょっと解説すると, ActionDispatchはtrusted_proxiesという値を設定可能です.
この値に指定したIPアドレスはremote_ipの返り値候補から除外されるので, いい具合に使うこともできそうです.
参考 https://qiita.com/yasu/items/da7ebdb01cb3209583df

ちなみにActionDispatch::Requestは今回first_client_ipメソッドを定義したRack::Request::Helpersをincludeしているため, Railsのコントローラ等でおもむろにrequest.first_client_ipなどと呼ぶことができます.
副産物ですが有効活用できるシーンもあるかもしれませんね.

おわりに

そもそもこんなもんどうしてもちょっと乱暴なやりかたになっちゃうんだよなあ
ちょっとしたモンキーパッチで快適なメンテ生活できるよ!

ちなみに当然ですがX_FORWARDED_FORは信用ならないヘッダーなので, そのあたりは気をつけて使用しましょう.

明日の『LITALICO Advent Calendar 2017』は@negiさんの「Webアクセシビリティ対応を考えてみた話」です. お楽しみに.