こんにちは。データ収集ツール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
listen
にdefault_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。
- リクエストライン上のホスト名
- Hostヘッダー上のホスト名
- ルーティングされた仮想サーバー名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_part
をproxy_pass
設定に追加します。
proxy_pass $scheme://$host$port_part$request_uri;
これで、リクエストラインにポート番号がある場合に、ポート番号も含めてフォワードできるようになります。
なお、この例で紹介した正規表現は、次のようなリクエストラインを想定しています。
{METHOD} {Scheme}://{Host}:{Port}/{Path} {HTTP-Version}
- {Scheme}に記号文字は含まれない(
http
かhttps
を想定) - {Host}に
:
は含まれない(ホスト名に入りうる記号文字は.
と-
のみであると想定) - {Port}は0~9までの数字の羅列である
まとめ
nginxをフォワードプロキシとして使う場合の、フォワード先を制限する際の注意点を紹介しました。
なお、これはクリアコードのFluentd法人様向けサポートサービスの一環で実施した調査で得られた知見です。 このように、特定のソフトウェアに限らず、周辺のソフトウェア(nginx, Redis, Postfix, K8s, Embulkなど)をセットでサポートする事例やご相談もあります。
クリアコードはオープンソースソフトウェア全般を得意としておりますので、 何かお困りのことがありましたら、ぜひお問い合わせフォームからお気軽にご相談ください。
同様のサポート事例として次の記事もあるので、ぜひご覧ください。
-
default_server
が設定されていない同一listen
先の仮想サーバー群においては、設定上最初に読み込んだ仮想サーバーが自動でdefault_server
となります。 ↩ -
仮想サーバーのルーティングの仕組み: https://nginx.org/en/docs/http/server_names.html#virtual_server_selection ↩
-
$http_host
: https://nginx.org/en/docs/http/ngx_http_core_module.html#var_http_ ↩ -
$host
: https://nginx.org/en/docs/http/ngx_http_core_module.html#var_host ↩ -
server_name
が複数設定されている場合、最初に設定した値が仮想サーバー名となります。 ↩