NGINXリバースプロキシでTLS Server Name Indication (SNI)と異なるドメイン名のバックエンドホストへルーティングできちゃう件について

この記事は、[株式会社LabBase](https://labbase.co.jp/)の「[株式会社LabBase テックカレンダー Advent Calendar 2022](https://qiita.com/advent-calendar/2022/labbase)」の22日目の記事です。LabBase所属としては初の記事投稿となります。なお、昨日の記事は[ゲバラさんのこちらの記事](https://qiita.com/guevara-net/items/a0d2008d9e9685a2bff0)です。

私はいち研究者として、LabBaseのリサーチエンジニアの皆さんと共同の研究開発をしています。その研究開発の中で偶然見つけてしまった「当たり前だと思い込んでいたけど、実装は実はそうではなかった」というNGINXの事案を報告します。場合によっては、セキュリティインシデントになりかねず、見つけた時に冷や汗をかいた覚えがあります。

なお、タイトルではNGINXのみに言及していますが、Caddyでも同じ事案が発生します。NGINXでの事象を解説したあと、Caddyについても言及します。

## はじめに

まずは導入として、TLSのServer Name Indication (SNI)と、それを用いたNGINXリバースプロキシによるHTTPSのマルチドメインホスティングについて説明していきます。

### TLS Server Name Indication (SNI)

![clienthello.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/194702/2af16555-ee48-230a-e785-400340f8dcf4.png)

上図にTLSのセッション構築の概略図を記載します。[^tcp] TLSでは、**セッション構築のイニシーションメッセージであるClientHelloに、接続したいドメインの名前 (`server_name`) を平文で記載し、サーバに通知します**。これはServer Name Indication (SNI) と呼ばれており、TLS拡張の1つです。TLSにおいて、複数ドメインを1つのIPアドレス・ホストで捌くために、事実上必須の拡張となっています。このSNIに応じて、サーバは対応する証明書を含めて、クライアントに返答します。その後、証明書の検証などを完了し、TLSのセッションが構築されることとなります。

[^tcp]: この`server_name`に応じてTCPを利用するHTTP/2までの図ですが、HTTP/3でも基本は一緒です。

### NGINXによる、HTTPSリバースプロキシによるマルチドメインホスティング

![HTTPS Reverse Proxy for Multi Doamin Hosting](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/194702/67c22aa1-0909-bd96-455c-23526d369fe4.png)


さて、複数ドメインのHTTPSサービスをホストするための、TLSを終端するリバースプロキシにおいては、SNIによる接続管理が行われることとなります。すなわち、

1. クライアントから受け取ったClientHello内のSNIに応じて、返答する証明書を切り替えてTLSセッションを構築
2. `server_name`ごとに事前設定されたバックエンドサービスへ、HTTPメッセージを転送

することになります。

このようなリバースプロキシとしては、NGINXは最も利用されているソフトウェアの1つです。具体的に、以下のような設定で上図の仕事の設定ができます。

```conf:nginx.conf
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](https://hub.docker.com/r/jwilder/whoami)を利用してみます。

```yaml:docker-compose.yml
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で取得していますが、適当にオレオレ証明書を利用しても構いません。[^self-signed-cert] **2つのバックエンドサービスに対して、全く別のドメイン、および証明書を設定している**ことに注意してください。

```conf:nginx.conf
# このバックエンドサーバへルーティングするのは<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;
  }
}
```

[^self-signed-cert]: 実証に限定して、cURLで`--insecure`オプションをつければ、確認可能です。

NGINXの設定を書くのや証明書の生成・取得がめんどくさい場合、NGINXについては[`nginx-proxy`](https://github.com/nginx-proxy/nginx-proxy)を利用、証明書取得については[`acme-companion`](https://github.com/nginx-proxy/acme-companion)を利用しても、結果は一緒なので問題ありません。

### cURLしてみる

まずは普通にcURLしてみます。

```shell:
% curl https://<Domain A>/
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>

〜中略〜

</html>
```

```shell:
% curl https://<Domain B>/
I'm 22777b5d3e12
```

これが本来想定される動作ですね。では、`HOST`リクエストヘッダを変更してcURLでアクセスしてみます。

```shell:
% 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のバックエンドサーバに接続されてしまいました**。

当然逆もこのようになります。

```shell:
% curl -H "HOST: <Domain B>" https://<Domain A>/
I'm 22777b5d3e12
```

というわけで、**サーバに設定された証明書とは一切整合性をとることなく、実際に接続されるのは、`HOST`リクエストヘッダで指定されたバックエンドサービス**となっていることが確認できました。これを偶然見つけた時は驚きました。

## 対策: NGINXでSNIとHTTP HOSTリクエストヘッダとの整合性を保つためには

NGINX設定ファイル (`nginx.conf`) の各`server`ディレクティブに以下を追加することで、この問題の対策が可能です。

```conf:
if ($ssl_server_name != $host) {
  return 421;
}
```

これにより、TLS SNIと、HTTP `HOST`リクエストヘッダの一貫性が取れない場合に、[`421 misdirected request`](https://www.rfc-editor.org/rfc/rfc7540#section-9.1.2)を返し、アクセス拒否をすることが可能です。cURLのレスポンスも以下のようになります。

```shell:
% 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](https://www.rfc-editor.org/rfc/rfc6066#section-11.1)ではアプリケーションレイヤのプロトコルと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が返ってきます**。

```shell:
% 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`です。

```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の結果です。

```shell:
% curl -H "HOST: <Domain B>" https://<Domain A>/
I'm 22777b5d3e12
```

CaddyではCaddyfileのGlobal Optionへ、以下のように[`strict_sni_host`](https://caddyserver.com/docs/caddyfile/options#strict-sni-host)を追加することで、この対策が可能です。これにより、もしTLS SNIと`HOST`リクエストヘッダとの一貫性がない場合、`421`を返すようになります。

```Caddyfile:
{
  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の問題ではない](https://serverfault.com/questions/907961/nginx-responds-with-200-even-when-http-host-header-is-different-from-sni)という返答が返ってきているとのことです。

> 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リバースプロキシ](https://github.com/junkuirhara/rust-rpxy/)を自作しました。

> GitHub **[junkurihara/rust-rpxy](https://github.com/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](https://qiita.com/takahiro-yamada)さんです！期待していてください！