この記事は、株式会社LabBaseの「株式会社LabBase テックカレンダー Advent Calendar 2022」の22日目の記事です。LabBase所属としては初の記事投稿となります。なお、昨日の記事はゲバラさんのこちらの記事です。
私はいち研究者として、LabBaseのリサーチエンジニアの皆さんと共同の研究開発をしています。その研究開発の中で偶然見つけてしまった「当たり前だと思い込んでいたけど、実装は実はそうではなかった」というNGINXの事案を報告します。場合によっては、セキュリティインシデントになりかねず、見つけた時に冷や汗をかいた覚えがあります。
なお、タイトルではNGINXのみに言及していますが、Caddyでも同じ事案が発生します。NGINXでの事象を解説したあと、Caddyについても言及します。
はじめに
まずは導入として、TLSのServer Name Indication (SNI)と、それを用いたNGINXリバースプロキシによるHTTPSのマルチドメインホスティングについて説明していきます。
TLS Server Name Indication (SNI)
上図にTLSのセッション構築の概略図を記載します。1 TLSでは、セッション構築のイニシーションメッセージであるClientHelloに、接続したいドメインの名前 (server_name
) を平文で記載し、サーバに通知します。これはServer Name Indication (SNI) と呼ばれており、TLS拡張の1つです。TLSにおいて、複数ドメインを1つのIPアドレス・ホストで捌くために、事実上必須の拡張となっています。このSNIに応じて、サーバは対応する証明書を含めて、クライアントに返答します。その後、証明書の検証などを完了し、TLSのセッションが構築されることとなります。
NGINXによる、HTTPSリバースプロキシによるマルチドメインホスティング
さて、複数ドメインのHTTPSサービスをホストするための、TLSを終端するリバースプロキシにおいては、SNIによる接続管理が行われることとなります。すなわち、
- クライアントから受け取ったClientHello内のSNIに応じて、返答する証明書を切り替えてTLSセッションを構築
-
server_name
ごとに事前設定されたバックエンドサービスへ、HTTPメッセージを転送
することになります。
このようなリバースプロキシとしては、NGINXは最も利用されているソフトウェアの1つです。具体的に、以下のような設定で上図の仕事の設定ができます。
server {
server_name www.example.org;
listen 443 ssl http2;
listen [::]:443 ssl http2;
# www.example.com の証明書・秘密鍵
ssl_certificate /path/to/com.cert;
ssl_certificate_key /path/to/net.key;
# backend.example.comへルーティング
location / {
proxy_pass http://backend.example.com:80;
}
}
server {
server_name www.example.net;
listen 443 ssl http2;
listen [::]:443 ssl http2;
# www.example.net の証明書・秘密鍵
ssl_certificate /path/to/net.cert;
ssl_certificate_key /path/to/net.key;
# backend.example.netへルーティング
location / {
proxy_pass http://backend.example.net:8000;
}
}
server
ディレクティブは、ApacheでいうVirtualHost
です。NGINXは、クライアントからClientHelloを受け取ったのち、SNIで通知されるドメイン名と一致するserver_name
を持つserver
ディレクティブを決定します。そして、そのserver
ディレクティブの設定を、TLSの構築やバックエンドサービスへのルーティングへ共通して反映する…
……する、と思っていた時期が私にもありました…
この記事で言いたいこと
NGINXリバースプロキシでは、デフォルトでSNIとHOST
リクエストヘッダの一貫性が考慮されない
リバースプロキシとしてのNGINXは、TLSの構築と、バックエンドサービスへのルーティングにおいて、反映されるserver
ディレクティブに、デフォルトで一貫性がありません。つまり、あるドメインについてTLSセッションを構築した場合、そのドメインについて設定した本来のルーティング先とは、全く異なるバックエンドサービスへルーティングされるケースがあります。
具体的には、以下のドメイン名と、server_name
エントリが一致するserver
ディレクティブが、別個に反映されることになります。
- TLSの構築: TLSのClientHello内のSNI
- バックエンドへのルーティング: TLS構築後に流れてくるHTTPの
HOST
リクエストヘッダあるいは:authority
擬似ヘッダ (以下ではまとめてHOST
リクエストヘッダとして簡単化します)
つまり、TLS SNIとHOST
リクエストヘッダに一貫性を保たないクライアントがいた場合、想定外の事象を引き起こす可能性があることを意味します。
ただし、設定ファイルにif文を入れるような信じられない対応をすれば、このようなことは起きません。これについても最後の方に記載します。
想定されるセキュリティ上の懸念
SNIとHOST
リクエストヘッダの一貫性が取れなくともルーティング可能、という状況は、例えば以下のようなセキュリティ上の懸念があります。
まず、2つのドメインがTLS (HTTPS) を終端するリバースプロキシによって
- Url
https://tadashii.example.com
-> バックエンドホストtadashii
へルーティング - Url
https://akui.example.org
-> バックエンドホストakui
へルーティング
となることを意図してルーティング設定がなされているとします。
しかし、本記事で紹介するNGINXの動作を考慮していないと、ドメインtadashii.example.com
に対してTLS接続した後に、バックエンドホストakui
へにアクセスすることが可能という、意図しない状況を生みます。これは、ドメインtadashii.example.com
の証明書の信頼性に依存してakui
へアクセス可能ということを意味します。
クライアントソフトウェア (e.g., ブラウザ) の実装次第で、TLS通信の上では正規ホストにアクセスしていると見せかけ、実際は正規ホストとは異なるバックエンドホストにアクセスさせることも可能ということです。これは、同一リバースプロキシで捌かれる、複数のドメインおよびバックエンドホストのオーナがそれぞれ異なるような場合、特に考慮しなければならないような事項です。Cookieとかの秘密情報が意図しないバックエンドホストへ漏洩するような、潜在的なセキュリティリスクになりうるからです。
実証してみる
環境設定
dockerで実験環境をサクッと立ち上げます。NGINXにリバースプロキシをさせて、2つのドメインをそれぞれ別のバックエンドサービス (コンテナ) へルーティングしていきます。今回は、バックエンドサーバとして普通のWebサーバとしてのNGINX、それとコンテナIDを返すjwilder/whoamiを利用してみます。
version: '3.9'
services:
nginx:
image: nginx:latest
container_name: proxy-nginx
ports:
- 80:80
- 443:443
restart: unless-stopped
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
- ./certs:/etc/nginx/certs:ro
# https://<Domain_A>/のルーティング先サービス
backend-nginx:
image: nginx:latest
container_name: backend-nginx
restart: unless-stopped
# https://<Domain_B>/のルーティング先サービス
backend-whoami:
image: jwilder/whoami:latest
container_name: backend-whoami
restart: unless-stopped
NGINXリバースプロキシの設定(nginx.conf
)は、以下のような超最低限のものを利用します。今回、証明書はLet's Encryptで取得していますが、適当にオレオレ証明書を利用しても構いません。2 2つのバックエンドサービスに対して、全く別のドメイン、および証明書を設定していることに注意してください。
# このバックエンドサーバへルーティングするのは<Domain A>へのアクセスのみ
server {
server_name <Domain A>;
listen 443 ssl http2;
listen [::]:443 ssl http2;
# Domain A の証明書・秘密鍵
ssl_certificate /etc/nginx/certs/<Domain A>.crt;
ssl_certificate_key /etc/nginx/certs/<Domain A>.key;
# ルーティング先はbackend-nginxの80ポート。
location / {
proxy_pass http://backend-nginx:80;
}
}
server {
# このバックエンドサーバへルーティングするのは<Domain B>へのアクセスのみ
server_name <Domain B>;
listen 443 ssl http2;
listen [::]:443 ssl http2;
# Domain B の証明書・秘密鍵
ssl_certificate /etc/nginx/certs/<Domain B>.crt;
ssl_certificate_key /etc/nginx/certs/<Domain B>.key;
# ルーティング先はbackend-whoamiの8000ポート。
location / {
proxy_pass http://backend-whoami:8000;
}
}
NGINXの設定を書くのや証明書の生成・取得がめんどくさい場合、NGINXについてはnginx-proxy
を利用、証明書取得についてはacme-companion
を利用しても、結果は一緒なので問題ありません。
cURLしてみる
まずは普通にcURLしてみます。
% curl https://<Domain A>/
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
〜中略〜
</html>
% curl https://<Domain B>/
I'm 22777b5d3e12
これが本来想定される動作ですね。では、HOST
リクエストヘッダを変更してcURLでアクセスしてみます。
% curl -vv -H "HOST: <Domain A>" https://<Domain B>/
〜中略〜
〜Domain Bのport 443へ接続〜
* Connected to <Domain B> (<IP Addr>) port 443 (#0)
〜中略〜
〜Domain Bの証明書 (CN/SANに注目) が提示され、その検証も成功〜
* Server certificate:
* subject: CN=<Domain B>
* start date: Nov 9 17:08:21 2022 GMT
* expire date: Feb 7 17:08:20 2023 GMT
* subjectAltName: host "<Domain B>" matched cert's "<Domain B>"
* issuer: C=US; O=Let's Encrypt; CN=R3
* SSL certificate verify ok.
* Using HTTP2, server supports multiplexing
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* h2h3 [:method: GET]
* h2h3 [:path: /]
* h2h3 [:scheme: https]
* h2h3 [:authority: <Domain A>]
* h2h3 [user-agent: curl/7.86.0]
* h2h3 [accept: */*]
* Using Stream ID: 1 (easy handle 0x14e00be00)
> GET / HTTP/2
〜ここでHOSTリクエストヘッダはDomain Aを示す〜
> Host: <Domain A>
> user-agent: curl/7.86.0
> accept: */*
〜中略〜
〜Domain Aに接続され、コンテンツがGETされる〜
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
〜中略〜
</html>
* Connection #0 to host <Domain B> left intact
……おい……TLS SNIは無視され、HOST
ヘッダで指定したDomain Aのバックエンドサーバに接続されてしまいました。
当然逆もこのようになります。
% curl -H "HOST: <Domain B>" https://<Domain A>/
I'm 22777b5d3e12
というわけで、サーバに設定された証明書とは一切整合性をとることなく、実際に接続されるのは、HOST
リクエストヘッダで指定されたバックエンドサービスとなっていることが確認できました。これを偶然見つけた時は驚きました。
対策: NGINXでSNIとHTTP HOSTリクエストヘッダとの整合性を保つためには
NGINX設定ファイル (nginx.conf
) の各server
ディレクティブに以下を追加することで、この問題の対策が可能です。
if ($ssl_server_name != $host) {
return 421;
}
これにより、TLS SNIと、HTTP HOST
リクエストヘッダの一貫性が取れない場合に、421 misdirected request
を返し、アクセス拒否をすることが可能です。cURLのレスポンスも以下のようになります。
% curl -H "HOST: <Domain B>" https://<Domain A>/
<html>
<head><title>421 Misdirected Request</title></head>
<body>
<center><h1>421 Misdirected Request</h1></center>
<hr><center>nginx/1.23.3</center>
</body>
</html>
なお421 Misdirected Request
は、リクエストURLのScheme (HTTPS) とAuthorityとの組み合わせの整合性が取れない場合、返答可能な400番台HTTPレスポンスです。TLS SNIとAuthority (=HOST
) の組み合わせがおかしいので、これを返すのが自然ですね。
421 Misdirected Request
This can be sent by a server that is not configured to produce responses for the combination of scheme and authority that are included in the request URI.
実際に、RFC6066ではアプリケーションレイヤのプロトコルとSNIの示すドメイン (server_name
) が異ならないか否かをチェックすること、と明示されています。
Since it is possible for a client to present a different server_name in the application protocol, application server implementations that rely upon these names being the same MUST check to make sure the client did not present a different name in the application protocol.
Q&A
ApacheやCaddyはどうなのか?
Apache
デフォルトで、SNIとHOST
リクエストヘッダの一貫性が担保されていないと421が返ってきます。
% curl -vv -H "HOST: <Domain A>" https://<Domain B>/
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>421 Misdirected Request</title>
</head><body>
<h1>Misdirected Request</h1>
<p>The client needs a new connection for this
request as the requested host name does not match
the Server Name Indication (SNI) in use for this
connection.</p>
</body></html>
Caddy
……Caddyよ、お前もか……。CaddyでもNGINXと同じ事項が確認できました。以下、サンプルのCaddyfile
です。
{
# 取得済み証明書・秘密鍵を利用する
auto_https disable_certs
}
<Domain A> {
tls /certs/<Domain A>.crt /certs/<Domain A>.key
reverse_proxy backend-nginx:80
}
<Domain B> {
tls /certs/<Domain B>.crt /certs/<Domain B>.key
reverse_proxy backend-whoami:8000
}
docker-compose.yml
はほぼNGINXの場合と一緒なため、割愛します。以下、cURLの結果です。
% curl -H "HOST: <Domain B>" https://<Domain A>/
I'm 22777b5d3e12
CaddyではCaddyfileのGlobal Optionへ、以下のようにstrict_sni_host
を追加することで、この対策が可能です。これにより、もしTLS SNIとHOST
リクエストヘッダとの一貫性がない場合、421
を返すようになります。
{
servers {
strict_sni_host
}
}
一括設定可能なのでNGINXよりマシですがデフォルトでstrict_sni_host
をONにして欲しいですね…
クライアント証明書を利用している場合、同じ懸念はあるのか?
2つのドメインについてHTTPSリバースプロキシを構築している場合、一方はクライアント証明書による認証があり、他方はない場合を想定します。クライアント認証もserver
ディレクティブ内に記載される設定です。このとき、
- 認証を行うドメインのバックエンドサービスに対して、
- 認証のないドメインについて構築したTLSから認証なしでアクセスできるかどうか、
という懸念がありますね。これは、NGINX・Caddyともに不可能です。ただし、Caddyは明示的にstrict_sni_host insecure_off
としてやれば疎通してしまうようです。
まとめ
TLS SNIと、バックエンドホストのドメイン名との一貫性が保たれ、意図しないバックエンドサービスへルーティングさせないことは、リバースプロキシを運用する時には暗黙のうちに仮定している事項だと思います。しかし、NGINXやCaddyではそういうわけではないという落とし穴がありました、という話でした。特にNGINXでは、その対策のために設定ファイルの各server
ディレクティブ全部にif文を書く必要があるのはどうなのか、は強く思うことです。
なお、この事象はNGINXのセキュリティチームにも報告されているらしいのですが、クライアント実装の問題だとしてNGINXの問題ではないという返答が返ってきているとのことです。
I have raised this to the nginx security team who say that it is not a security flaw with nginx, and that it is up to the client to validate certificates, and then not to send malformed requests for hosts that they should be accessing over that connection.
この事案を調べるにつれ、NGINXチームの対応状況もどうもアレだったということもあり、SNIとHTTP HOST
リクエストヘッダとの一貫性をちゃんと保つRust製HTTPSリバースプロキシを自作しました。
GitHub junkurihara/rust-rpxy: A simple and ultrafast http reverse proxy serving multiple domain names and terminating TLS over http/1.1, 2 and 3, written in Rust
多機能ではないものの、HTTPSマルチドメインにももちろん対応し、NGINX並に安定・高速に動作しています。CaddyとApacheは遅くてたまらん。
つぎの記事は
次の記事は@takahiro-yamadaさんです!期待していてください!