ククログ

株式会社クリアコード > ククログ > nginxでフォワード先を制限する際に気をつけるべきこと

nginxでフォワード先を制限する際に気をつけるべきこと

こんにちは。データ収集ツールFluentdやWebサーバーnginxなどのサポートサービスを担当している福田です。

この記事では、nginxでフォワード先を制限する際の注意点を紹介します。

nginxをフォワードプロキシとして使うケースを考えます。 その際、セキュリティー等の目的でフォワードする宛先を制限したい、ということがあります。 これは一見簡単な設定で実現できそうかもしれませんが、実は注意するべき点が多くあります。

例えば、Hostヘッダー偽装(Hostヘッダーフォージェリ)に対処するため、$http_hostではなく$hostを使う、という話は有名かもしれません。 しかし、$hostはポート番号を含まないため、ポート番号も含めてフォワードするには工夫が必要です。

このように宛先制限には注意点があります。 本記事ではそれらを解説します。

はじめに

フォワードプロキシとしてのnginxでフォワード先を制限する際は、次の点に注意が必要です。

  • default_serverを正しく設定する
  • Hostヘッダー偽装の対策をする
  • (Hostヘッダー偽装の対策として)$http_hostではなく$hostを使うと、ポート番号をフォワードできなくなる

以下では、シンプルな設定例から順番に注意点を確認していきます。

シンプルな設定例(Hostヘッダー偽装の影響あり)

まずはシンプルな設定例として、次のような仮想サーバー設定を考えてみます。

# 拒否用仮想サーバー
server {
  listen 8080 default_server;
  server_name _;
  deny all;
}

# 許可用仮想サーバー
server {
  server_name foo;
  server_name bar;
  server_name baz;

  listen 8080;
  allow all;

  location / {
      proxy_pass $scheme://$http_host$request_uri;
  }
}

この設定では、許可用と拒否用の2つの仮想サーバーを設定しています。 フォワード先のホスト名がfoo, bar, bazのいずれかであれば、許可用の仮想サーバーにルーティングされてフォワードされます。 そうでなければ、拒否用の仮想サーバーにルーティングされて拒否されます。

拒否用仮想サーバー設定にポイントが2つあります。

  • server_name_に設定する
    • ホスト名のマッチによってルーティングされる可能性を排除する1
  • listendefault_serverを付与する
    • ルーティング先のないリクエストが、最終的にルーティングされる

これにより、フォワード先のホスト名がfoo, bar, bazのどれでもないリクエストは、マッチする仮想サーバーがないため、default_serverである拒否用仮想サーバーにルーティングされて、拒否されることになります。

default_serverを正しく設定しないと、意図しない仮想サーバーが自動でdefault_serverとなって、マッチしないリクエストが意図しない宛先へフォワードされてしまうことがありえる2ので、注意しましょう。

さて、以上の設定で宛先の制限を実現できていそうに見えますが、実はこれではHostヘッダー偽装で突破できてしまいます。 例えば、次のようにHostヘッダーを任意の宛先にすることで、fooに通信すると見せかけて、任意の宛先へフォワードさせることができてしまいます。

$ curl http://foo --proxy {nginx} --header 'Host: {任意の宛先}'

これは次が原因です。

  • nginxは、リクエストライン上のホスト名を読み取り、ルーティングする仮想サーバーを決めようとする (厳密にはもっと複雑です3
  • $http_host変数には、(リクエストライン上のホスト名ではなく)Hostヘッダーの値が入る4

先ほどの偽装例だと、リクエストライン上はfooホストに通信しようとするので、許可用仮想サーバーにルーティングされます。 結果$scheme://$http_host$request_uriにフォワードされますが、この際$http_hostにはHostヘッダーの値が入るので、ここでfooではなく偽装された任意の宛先にフォワードしてしまう、ということになります。

$http_hostではなく$hostを使う(ポート番号をフォワードできなくなる)

Hostヘッダー偽装の問題は、次のように$http_hostではなく$hostを使うことで一応解決できます。

proxy_pass $scheme://$host$request_uri;

$hostの値は、次の優先度で決まります5

  1. リクエストライン上のホスト名
  2. Hostヘッダー上のホスト名
  3. ルーティングされた仮想サーバー名6

$http_hostとは異なり、リクエストライン上のホスト名が優先されるので、先ほどのようなHostヘッダー偽装を防げるわけです。 リクエストラインのホスト名を使えない場合はHostヘッダーを見ますが、この場合は仮想サーバーのルーティングもHostヘッダーを見る3ので、拒否用仮想サーバーで拒否できます。

しかし、この方法は、ポート番号をフォワードできなくなる、という別の問題を生じさせます。 $http_hostはポート番号も含みますが、$hostのホスト名の部分のみになるからです。

ポート番号も含めたフォワードを可能にする

mapと正規表現を使って自前でパースする方法を紹介します。

例えば次のようにポート番号をリクエストライン($request)から抽出し、$port_partという変数にセットすることができます。

map $request $port_part {
    "~^\S+ \S+:\/\/[a-zA-Z0-9.-]+(:[0-9]+)\S* \S+$" $1;
}

mapは、httpディレクティブの直下で行う必要がある点に注意してください。 つまり、serverディレクティブの外に記載する形となります。

この例では、先頭のコロンも含めてポート番号を抽出します。 正規表現がマッチしない場合、つまりポート番号がリクエストラインに存在しない、もしくはリクエストラインが想定と異なる形式である場合には、空文字になります。

例:

  • $request: GET http://127.0.0.1:8080/ HTTP/1.1
    • $port_part: :8080
  • $request: GET http://127.0.0.1/ HTTP/1.1
    • $port_part: (空文字)

あとは、次のように$port_partproxy_pass設定に追加します。

proxy_pass $scheme://$host$port_part$request_uri;

これで、リクエストラインにポート番号がある場合に、ポート番号も含めてフォワードできるようになります。

なお、この例で紹介した正規表現は、次のようなリクエストラインを想定しています。

  • {METHOD} {Scheme}://{Host}:{Port}/{Path} {HTTP-Version}
  • {Scheme}に記号文字は含まれない(httphttpsを想定)
  • {Host}に:は含まれない(ホスト名に入りうる記号文字は.-のみであると想定)
  • {Port}は0~9までの数字の羅列である

まとめ

nginxをフォワードプロキシとして使う場合の、フォワード先を制限する際の注意点を紹介しました。

なお、これはクリアコードのFluentd法人様向けサポートサービスの一環で実施した調査で得られた知見です。 このように、特定のソフトウェアに限らず、周辺のソフトウェア(nginx, Redis, Postfix, K8s, Embulkなど)をセットでサポートする事例やご相談もあります。

クリアコードはオープンソースソフトウェア全般を得意としておりますので、 何かお困りのことがありましたら、ぜひお問い合わせフォームからお気軽にご相談ください。

同様のサポート事例として次の記事もあるので、ぜひご覧ください。

  1. RFC952では、ホスト名に_を使用できません。

  2. default_serverが設定されていない同一listen先の仮想サーバー群においては、設定上最初に読み込んだ仮想サーバーが自動でdefault_serverとなります。

  3. 仮想サーバーのルーティングの仕組み: https://nginx.org/en/docs/http/server_names.html#virtual_server_selection

  4. $http_host: https://nginx.org/en/docs/http/ngx_http_core_module.html#var_http_

  5. $host: https://nginx.org/en/docs/http/ngx_http_core_module.html#var_host

  6. server_nameが複数設定されている場合、最初に設定した値が仮想サーバー名となります。