株式会社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
以下に配置する.
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
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
によって取得されています.
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
の最後の値を返り値としていることです.
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との比較を行うようにオーバーライドしてしまいました.
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
の先頭の値を返すようにしています.
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::GetIp
のcalculate_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アクセシビリティ対応を考えてみた話」です. お楽しみに.