LoginSignup
7
4

More than 5 years have passed since last update.

Rackアプリケーションでプライベートネットワークからのプロキシサーバー越しのリクエストから送信元IPアドレスを得る

Last updated at Posted at 2017-03-03

最初に結論

条件

  1. クライアントがプライベートネットワークにいる
  2. プロキシサーバーとRailsアプリケーションをUNIXドメインソケットで接続している

現象

Rackのrequest.ipやRailsのrequest.remote_ipで、送信元IPアドレスが取れません。

対策

  1. Rack::Requestにモンキーパッチを当てる
  2. X-Forwarded-Forヘッダーから直接取得する
  3. Railsなら、config.action_dispatch.trusted_proxiesを変更する

のいずれかの方法で取れます。

構成

クライアント - nginx - Rackアプリケーション

これだけならば、割と一般的な構成です。

ただし、クライアントはプライベートネットワークにいます。
つまり送信元IPアドレスはプライベートIPアドレスです。

また、nginxとRackアプリケーションは何らかの方法でプロセス間通信します。
Rackアプリケーションから見える送信元IPアドレスはnginxのIPアドレスです。
クライアントの送信元IPアドレスではありません。

大抵、nginxはX-Forwarded-Forヘッダーに送信元IPアドレスを格納しRackアプリケーションに伝えます。

状況説明1 Rack編

プロキシは送信元IPアドレスをX-Forwarded-Forヘッダーに格納する

X-Forwarded-For - Wikipedia

X-Forwarded-For (XFF) とは、HTTPヘッダフィールドの一つ。HTTPプロキシサーバまたは負荷分散装置(ロードバランサ)を経由してウェブサーバに接続するクライアントの送信元IPアドレスを特定する際のデファクトスタンダードである

nginxではproxy_set_headerディレクティブを使い

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

を設定すると、送信元IPアドレスをX-Forwarded-Forヘッダーに追加できます。

RackはリクエストのX-Forwarded-Forヘッダーを参照する

Rack::Request#ip

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

X-Forwarded-Forヘッダーを参照して送信元IPアドレスを取得します。

RackはリクエストのX-Forwarded-ForヘッダーのプライベートIPを無視する

しかし、Rack::Request#ipではX-Forwarded-Forヘッダーに対して、reject_trusted_ip_addressesという処理をします。

forwarded_ips = split_ip_addresses(get_header('HTTP_X_FORWARDED_FOR'))

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

reject_trusted_ip_addressesではtrusted_proxy?なアドレスを消します。

def reject_trusted_ip_addresses(ip_addresses)
  ip_addresses.reject { |ip| trusted_proxy?(ip) }
end

trusted_proxy?なアドレスはプライベートIPアドレスを表現した正規表現です。

def trusted_proxy?(ip)
  ip =~ /\A127\.0\.0\.1\Z|\A(10|172\.(1[6-9]|2[0-9]|30|31)|192\.168)\.|\A::1\Z|\Afd[0-9a-f]{2}:.+|\Alocalhost\Z|\Aunix\Z|\Aunix:/i
end

再現実験

実験用Rackアプリケーション

次のようなRackアプリケーションを用意します。

my_rack_app.rb
#! /usr/bin/env ruby

require 'rack'

app = Proc.new do |env|
  request = Rack::Request.new env
  ['200', {'Content-Type' => 'text/html'}, ["request.ip #{request.ip}, X-Forwarded-For #{env['HTTP_X_FORWARDED_FOR']}"]]
end

Rack::Handler::WEBrick.run app, {Host: '0.0.0.0'}
source 'https://rubygems.org'

gem 'rack'
FROM ruby:2.4-alpine

COPY Gemfile /

RUN bundle install --clean

COPY my_rack_app.rb /

CMD ["./my_rack_app.rb"]

起動

chmod +x my_rack_app.rb
docker build -t my-ruby-app .
docker run --rm -p 80:8080 my-ruby-app

実験

request.ipは

X-Forwarded-ForヘッダーがなければリモートIPを返す

curl localhost                                                                                                                                                                   
request.ip 172.17.0.1, X-Forwarded-For

X-Forwarded-ForヘッダーにグローバルIPアドレスがあればそれを返す

curl --header "X-Forwarded-For: 1.2.3.4" localhost
request.ip 1.2.3.4, X-Forwarded-For 1.2.3.4

X-Forwarded-Forヘッダー中のプライベートIPアドレスを無視する

curl --header "X-Forwarded-For: 192.168.0.200" localhost
request.ip 172.17.0.1, X-Forwarded-For 192.168.0.200
curl --header "X-Forwarded-For: 192.168.0.200 10.0.0.1 1.2.3.4" localhost
request.ip 1.2.3.4, X-Forwarded-For 192.168.0.200 10.0.0.1 1.2.3.4

172.17.0.1 の補足

Docker コンテナ・ネットワークの理解 — Docker-docs-ja 1.13.RC ドキュメント

Docker をインストールした全ての環境には、 docker0 と表示されるブリッジ( bridge )ネットワークが現れます。オプションで docker run --net=<ネットワーク名> を指定しない限り、Docker デーモンはデフォルトでこのネットワークにコンテナを接続します

docker network inspect bridgeコマンドでbridgeのネットワーク構成を見ると

[
    {
        "Name": "bridge",
        "Id": "f167ffba2da56ee97e2de8dbfb20831061a0e82c3ba56c4f20d1db1aea3d1ee3",
        "Created": "2017-03-02T03:17:55.885274307Z",
        "Scope": "local",
        "Driver": "bridge",
        "EnableIPv6": false,
        "IPAM": {
            "Driver": "default",
            "Options": null,
            "Config": [
                {
                    "Subnet": "172.17.0.0/16",
                    "Gateway": "172.17.0.1"
                }
            ]
        },
        "Internal": false,
        "Attachable": false,
        "Containers": {
            "5195b48d88a4db4a05411cf9c0f61b069394b15b5269662eed87914be9ca2c6a": {
                "Name": "eager_meninsky",
                "EndpointID": "a53f8f239f37ac487d47e643e9d125775ad7facb88d91da96c6c4049067f5bac",
                "MacAddress": "02:42:ac:11:00:02",
                "IPv4Address": "172.17.0.2/16",
                "IPv6Address": ""
            }
        },
        "Options": {
            "com.docker.network.bridge.default_bridge": "true",
            "com.docker.network.bridge.enable_icc": "true",
            "com.docker.network.bridge.enable_ip_masquerade": "true",
            "com.docker.network.bridge.host_binding_ipv4": "0.0.0.0",
            "com.docker.network.bridge.name": "docker0",
            "com.docker.network.driver.mtu": "1500"
        },
        "Labels": {}
    }
]

"Gateway": "172.17.0.1"と表示されています。
ホストOSからのリクエストはこのGatewayとして送られます。
このため送信元IPアドレスは172.17.0.1です。

状況のまとめ

Rackは送信元IPアドレスを取得するために、X-Forwarded-Forヘッダーを見ます。
ただしその中のプライベートIPアドレスは無視します。

これは一般的なインターネットからのアクセスを想定した場合、大変うまく動きます。
例えば

クライアント - ELB - nginx - Rackアプリケーション

という構成の場合、プライベートネットワーク中のプロキシサーバーが何台居ても、気にする必要がありません。

状況説明2 nginx編

先ほどの実験ではrequest.ipで送信元IPアドレスが取得できています。
REMOTE_ADDR変数に送信元IPアドレスが格納されており、request.ipで取れます。
特に問題ないように思えます。

プロキシサーバーを介した場合、送信元IPアドレスが失われます。

UNIXドメインソケット vs TCP/IP

プロキシサーバーとアプリケーションサーバを接続する方法にはUNIXドメインソケットとTCP/IPを使う方法があります。

UNIXドメインソケット - Wikipedia

UNIXドメインソケット(英: UNIX domain socket)や IPCソケット とは、単一のオペレーティングシステム内で実行されるプロセス間でデータを交換するためのデータ通信の終点

UNIXドメインソケットは単一OS内のプロセス間通信の仕組みです。
単一OS上のプロセス間でしか使えませんが、TCP/IPレイヤーを介しない分高速に動作します。

UNIX domain sockets know that they’re executing on the same system, so they can avoid some checks and operations (like routing); which makes them faster and lighter than IP sockets. So if you plan to communicate with processes on the same host, this is a better option than IP sockets.

パフォーマンスを求める場合TCP/IP接続よりUNIXドメインソケットを使いたいです。

nginxとアプリケーションサーバのプロセス間通信が、システム全体のボトルネックに成るとは限りません。一般にはDBなどのストレージアクセスやRubyでのロジックの方が時間が掛かるでしょう。絶対に、UNIXドメインソケットを使わなければいけないわけではありません。それでも、より高速な接続方式が選択可能である方が嬉しいです。

TCP/IP接続の場合

まず、TCP/IP接続での送信元IPアドレスの伝達を試してみましょう。

準備

ファイルを二つ追加します。

docker-compose.yml
version: '2'
services:
  nginx:
    depends_on:
      - web
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./log:/var/log/nginx
    volumes_from:
      - web
  web:
    build: .
nginx.conf
events {}
http {
  server {
    listen 80;
    location / {
      proxy_pass http://web:8080;
    }
  }
}
docker-compose build

を実行して環境を作成します。

起動

docker-compose up

を実行すると

Starting racktest_web_1
Starting racktest_nginx_1
Attaching to racktest_web_1, racktest_nginx_1
web_1    | [2017-03-03 12:25:21] INFO  WEBrick 1.3.1
web_1    | [2017-03-03 12:25:21] INFO  ruby 2.4.0 (2016-12-24) [x86_64-linux]
web_1    | [2017-03-03 12:25:21] INFO  WEBrick::HTTPServer#start: pid=1 port=8080

と表示されます。
racktestはdocker-composeを実行しているフォルダ名によって変わります。

実行

ブラウザでlocalhostに接続すると

request.ip 172.19.0.3, X-Forwarded-For

と表示されます。
request.ipが172.19.0.3に変わりました。

docker network

docker network ls

を実行します。

NETWORK ID          NAME                                    DRIVER              SCOPE
f167ffba2da5        bridge                                  bridge              local
9e8aa4450aea        host                                    host                local
251cb96e4df9        none                                    null                local
7c644f2bd292        racktest_default                        bridge              local

racktest_defaultのようなネットワークが増えています。

docker network inspect racktest_default

を実行します。

[
    {
        "Name": "racktest_default",
        "Id": "7c644f2bd292368f911d7540c3ac8350cf8102c7825e82a658ecb0d838ac2436",
        "Created": "2017-03-02T10:58:57.837391679Z",
        "Scope": "local",
        "Driver": "bridge",
        "EnableIPv6": false,
        "IPAM": {
            "Driver": "default",
            "Options": null,
            "Config": [
                {
                    "Subnet": "172.19.0.0/16",
                    "Gateway": "172.19.0.1"
                }
            ]
        },
        "Internal": false,
        "Attachable": false,
        "Containers": {
            "46dc8476b81c5a44aa64413c81446318171202998ce66845e96ab1d40e755e6f": {
                "Name": "racktest_nginx_1",
                "EndpointID": "77c1b80018d6f643a9919a3e8154a5edd5d3c5a5fc92025fecdbbb3d372e2968",
                "MacAddress": "02:42:ac:13:00:03",
                "IPv4Address": "172.19.0.3/16",
                "IPv6Address": ""
            },
            "afc24a6875fe836c8bcb5f66d67fbd3bde609e1b59312d02cb9559b5ac3b3956": {
                "Name": "racktest_web_1",
                "EndpointID": "249d00c019582f72715a1b75e0e94beec12d8e987c583cf86db6b4befe64bac6",
                "MacAddress": "02:42:ac:13:00:02",
                "IPv4Address": "172.19.0.2/16",
                "IPv6Address": ""
            }
        },
        "Options": {},
        "Labels": {}
    }
]

"Name": "racktest_nginx_1というコンテナの"IPv4Address": "172.19.0.3/16"という値をみます。
nginxのIPアドレスが172.19.0.3という意味です。
送信元IPアドレス172.19.0.1は失われました。

UNIXドメインソケット接続の場合

準備

WebrickはUNIXドメインソケットが使えなさそうなので、thinを使います。
今までのRackアプリケーションと共存するためにthinディレクトリ配下にファイルを作成します。

thinはeventmachineというgemに依存しています。
eventmachineをgem installするには、いろいろ物入りです。

FROM ruby:2.3.3-alpine

ENV BUILD_PACKAGES="ruby-dev build-base"

RUN apk update && \
    apk upgrade && \
    apk add --no-cache --update\
    $BUILD_PACKAGES

COPY Gemfile /

RUN bundle install --clean

COPY my_rack_app.rb /

RUN mkdir -p /app/tmp/sockets
VOLUME /app/tmp/sockets

CMD ["./my_thin_app.rb"]

EXPOSE 8080

Gemfileにもthinを足します。

source 'https://rubygems.org'

gem 'rack'
gem 'thin'

アプリケーションの待ち受け先はアドレスではなくファイルです。

thin/my-thin-app.rb
#! /usr/bin/env ruby
require 'rack'
require 'thin'

app = Proc.new do |env|
    request = Rack::Request.new(env)
    ['200', {'Content-Type' => 'text/html'}, ["request.ip #{request.ip}, X-Forwarded-For #{env['HTTP_X_FORWARDED_FOR']}"]]
end

Rack::Handler.get('thin').run(app, :Host => '/app/tmp/sockets/my_app.socket')

docker-compose.ymlのwebbuildthinに変えます。

docker-compose.yml
version: '2'
services:
  nginx:
    depends_on:
      - web
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./log:/var/log/nginx
    volumes_from:
      - web
  web:
    build: thin

nginx.confもUNIXドメインソケットを使うように変更します

nginx.conf
events {}
http {
  upstream app {
    server unix:///app/tmp/sockets/my_app.socket;
  }
  server {
    listen 80;
    location / {
      #proxy_pass http://web:8080;
      proxy_pass http://app;
    }
  }
}

chmod +x thin/my_thin_app.rb
docker-compose build

を実行して環境を作成します。

起動

docker-compose up

を実行すると

Recreating racktest_web_1
Recreating racktest_nginx_1
Attaching to racktest_web_1, racktest_nginx_1

と表示されます。

実行

ブラウザでlocalhostに接続すると

request.ip 127.0.0.1, X-Forwarded-For

と表示されます。
UNIXドメインソケット接続では送信元IPアドレスが、
nginxのIPアドレスではなく127.0.0.1に変わります。

X-Forwarded-Forヘッダー

そこで前述のX-Forwarded-Forヘッダーを使い、nginxはリモートIPをアプリケーションサーバーに伝えます。

nginx.confにproxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;を追加します。

nginx.conf
events {}
http {
  upstream app {
    server unix:///app/tmp/sockets/my_app.socket;
  }
  server {
    listen 80;
    location / {
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_pass http://app;
    }
  }
}

Ctrl + cでdocker-compose一度終了し

docker-compose up

を実行します。
ブラウザでlocalhostに接続すると

request.ip 127.0.0.1, X-Forwarded-For 172.19.0.1

と表示されます。
X-Forwarded-Forヘッダーは表示されますがrequest.ipは127.0.0.1のままです。

これが「RackはリクエストのX-Forwarded-ForヘッダーのプライベートIPを無視する」振る舞いです。

対策

モンキーパッチ!

RackはRubyで書かれています。Rubyのクラスはオープンクラスです。
Rack::Requestを、外部から書き換えることができます。

次のモンキーパッチを当ててみましょう。

module Rack
  class Request
    def trusted_proxy?(ip)
      ip =~ /^127\.0\.0\.1$/
    end
  end
end

準備

my-thin-app.rbを変更します。

thin/my-thin-app.rb
#! /usr/bin/env ruby
require 'rack'
require 'thin'

module Rack
  class Request
    def trusted_proxy?(ip)
      ip =~ /^127\.0\.0\.1$/
    end
  end
end

app = Proc.new do |env|
    request = Rack::Request.new(env)
    ['200', {'Content-Type' => 'text/html'}, ["request.ip #{request.ip}, X-Forwarded-For #{env['HTTP_X_FORWARDED_FOR']}"]]
end

Rack::Handler.get('thin').run(app, :Host => '/app/tmp/sockets/my_app.socket')

起動

起動し直します。
ソースファイルを変えたのでbuildからやりなおします。

docker-compose build
docker-compose up

実行

ブラウザでlocalhostに接続すると

request.ip 172.19.0.1, X-Forwarded-For 172.19.0.1

と表示されます。
:clap: :clap: :clap: 無事送信元IPアドレスが取得できました。

ただし、モンキーパッチは変更対象のクラスの実装に依存します。
将来、Rackの実装変更時に影響を受けるかもしれません。

また、何らかのライブラリが同じ箇所を修正しているかもしれません。
もしそうであれば、予想しないライブラリに影響が出ます。

可能であれば、モンキーパッチではなく保証されたインターフェースを使って振る舞いを変更したいです。

直接 X-Forwarded-Forヘッダー を参照

プライベートネットワークからのリクエストの場合は
プロキシサーバの構成が決まっていて変更予定がないことがありそうです。
その場合は、これでも良さそうです。

RackではenvのHTTPで始まるキーへHTTPヘッダーが格納されています。

env['HTTP_X_FORWARDED_FOR']

これでX-Forwarded-Forが参照可能です。

X-Forwarded-Forヘッダーには複数のIPアドレスが含まれていることがあります。
注意してください。

RailsならRailsならtrusted_proxiesを設定で変更できる

RailsにはRack::Reqeuest#ipとほとんど同じ動きをする
ActionDispatch::Request#remote_ipがあります。

config/environments/*.rb
config.action_dispatch.trusted_proxies

を書けばrequest.remote_ipで使用するtrusted_proxiesを変更できます。

Railsの環境を作るのが面倒臭すぎるのでためせませんでしたー・・・orz

あと、いまのところログに反映されないっぽい!PRはマージされているっぽい1

参考

7
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
4