--resolveでは間に合わぬ、--connect-toだ

## 問題

`8080`番ポートで待ち受けているhttpsサーバがある。このサーバは`192.168.0.5/24`・`10.0.0.5/24`の2つのIPを持ち、またゲートウェイ`192.168.0.1/24 == 10.0.0.5/24`がポートフォワード`10.0.0.10:4444 -> 192.168.0.5:8080`をかけている。

サーバが証明書を持つドメインとして`example.com`・`secret.example.com`があり、どちらもAレコードとして`10.0.0.5`のみを持っているとする。

サーバは仮想ホスト`example.com`・`secret.example.com`を抱えており、Server Name Indication (SNI)を読んだ上で`Host`ヘッダからリクエストを仮想ホストに分配する。仮想ホストはホストヘッダについて次の2項目を確認する。

- ホスト名がSNIと一致していること
- ポート番号がリクエストを受け付けたポート番号(`8080`)と一致していること

この時、ネットワークBから`curl`コマンドを用い、ゲートウェイ経由で`https://secret.example.com:8080`にアクセスするにはどうすればよいか。

## 回答

```sh
curl --connect-to secret.example.com:8080:10.0.0.10:4444 https://secret.example.com:8080
```

## 解説

PPPoEを契約していても、より高速なIPoE(IPv4 over IPv6)経由で通信させたいという気持ちは湧くはずである。しかし多くのIPoEプロバイダでは操作不能かつ固定不能[^unfix]な、割り当てられたポートしか使えない。とはいえIPoEに応じてサーバのポートを調整するという作業はしたくない。ファイアウォールの調整や内部サービスとの衝突など、色々面倒だからだ。よって、ゲートウェイでポートフォワードを噛ませる形になるだろう。これは[問題](#問題)における`10.0.0.0/24`をWANに置き換えた時と同じネットワークになる。

[^unfix]: 稀に(工事後など)IPv4・IPv6 prefix・利用可能ポートの割り当てが変動する場合がある。

ところで、httpは仮想ホストを実現するための仕組みとして`Host`ヘッダがあるが、httpsになるとSNIによってもホスト名が送られる。さらに`Host`ヘッダにはポート番号も書かれている。これらの指定子について整合性チェックを取るようRFCにも書いてあるのだが、送信するべきSNI・`Host`ヘッダと実際の送信先が異なるようなクエリはURLでは表現できない。IPとポート番号を直書きしたURLではサーバまで届きはするものの、その後の不整合を理由に適切な証明書が返ってこなかったり、`400 Bad Request`とか`421 Misdirected Request`を返されたりすることになる。

と、IPoE経由で自前のhttpsサーバを叩きに行くという状況がこの問題の背景な訳だが、シェルスクリプトから叩きたいこともよくあることだろう。最も標準的なコマンドラインツールである`curl`にはさまざまなオプションがついているから、適宜活用して適切なリクエストを構成できる。

以下ではより弱い問題から始めてこの問題の最適解を見ていく。

### 準備問題1: httpサーバ

TCPには送信先ホスト名を伝達する機能は存在せず、上位レイヤで勝手に実装されている。httpでは`Host`ヘッダにホスト名が記述されるから、これを手で書き換えれば良い:

```sh
curl -H "Host: secret.example.com:8080" http://10.0.0.10:4444
```

### 準備問題2: 受付ポートと`Host`ヘッダの不一致を無視するhttpsサーバ

httpsサーバではその前段であるTLSで証明書のやり取りをするが、`curl`は送られてきたサーバ証明書のドメイン名がURLのドメインと一致しない場合、エラーを出して中断するのがデフォルトの挙動である。そこで`-k`オプションで証明書の整合性の確認をオフにして

```sh
curl -k -H "Host: secret.example.com:8080" https://10.0.0.10:4444
```

で上手くいく……かは実装による。仮想ホストの振り分けが`Host`ヘッダのみで行われているならこれで良いが、SNIによって振り分けていた場合、生のIPによるアクセスではSNIが送信されないので、デフォルトで降ってくる証明書のドメインと紐づいた仮想ホストに流されうる[^defaulthost]。今回の場合、デフォルトのホストが`example.com`であった場合、`https://example.com:8080`が見えてしまう。

[^defaulthost]: 普通はSNIがついていなかったらフォールバックとしてHostでルーティングするとは思うが、存在しうる実装である。この実装なら少なくともSNI Spoofingは発生しないという利点がある。

そうでなくとも、TLSでは`example.com`の証明書で認証し、httpとしては`secret.example.com`に繋ぐというのは、かなり攻撃に近い接続[^attack]であるし、サーバ証明書をちゃんと確認しないのも危なっかしい。`-k`オプションを使わない解決策はあるだろうか。

[^attack]: 実例: [NGINXリバースプロキシでTLS Server Name Indication (SNI)と異なるドメイン名のバックエンドホストへルーティングできちゃう件について](https://qiita.com/jqtype/items/bd6f0d819944ef954d88)

用件を考え直すと、要するにTLSやhttpには`secret.example.com`に送っている気分になってもらいつつ、実際の送信先は`10.0.0.10`にできれば良いわけで、つまりドメイン解決だけ騙せば良いのである。この辺りの用語で検索すると`--resolve`オプションが出てくる:

```sh
curl --resolve secret.example.com:4444:10.0.0.10" http://secret.example.com:4444
```

これで、`secret.example.com:4444`へのリクエストは`10.0.0.10`(のポート`4444`)に送られるようになり、TLS・httpは`secret.example.com:4444`に繋いでいる気分で動いてくれる。

### 本題: 受付ポートと`Host`ヘッダの不一致を取り締まるhttpsサーバ

ところで、これでも`Host`ヘッダは`secret.example.com:4444`と、ポート`4444`に繋いでいる気分なのだが、それで問題ないだろうか？httpに関するRFCを覗くと、

[RFC 9110 7.4. Rejecting Misdirected Requests](https://datatracker.ietf.org/doc/html/rfc9110.html#section-7.4)より

>Once a request is received by a server and parsed sufficiently to determine its target URI, the server decides whether to process the request itself, forward the request to another server, redirect the client to a different resource, respond with an error, or drop the connection. This decision can be influenced by anything about the request or connection context, but is specifically directed at whether the server has been configured to process requests for that target URI and whether the connection context is appropriate for that request.
>
>For example, a request might have been misdirected, deliberately or accidentally, such that the information within a received Host header field differs from the connection's host or port. If the connection is from a trusted gateway, such inconsistency might be expected; otherwise, it might indicate an attempt to bypass security filters, trick the server into delivering non-public content, or poison a cache. See Section 17 for security considerations regarding message routing.
>
>Unless the connection is from a trusted gateway, an origin server MUST reject a request if any scheme-specific requirements for the target URI are not met. In particular, a request for an "https" resource MUST be rejected unless it has been received over a connection that has been secured via a certificate valid for that target URI's origin, as defined by Section 4.2.2.
>
>The 421 (Misdirected Request) status code in a response indicates that the origin server has rejected the request because it appears to have been misdirected (Section 15.5.20).

と、スキームごとの整合性チェックに失敗したら`421 Misdirected Request`を返すよう規定されている。特に証明書のドメイン名との不一致は**MUST**でリジェクトするよう指示されている。

加えて[RFC 9110 4.3.3. https Origins](https://datatracker.ietf.org/doc/html/rfc9110#section-4.3.3)より

> An origin server might be unwilling to process requests for certain target URIs even when they have the authority to do so. For example, when a host operates distinct services on different ports (e.g., 443 and 8000), checking the target URI at the origin server is necessary (even after the connection has been secured) because a network attacker might cause connections for one port to be received at some other port. Failing to check the target URI might allow such an attacker to replace a response to one target URI (e.g., "https:\//example.com/foo") with a seemingly authoritative response from the other port (e.g., "https:\//example.com:8000/foo").

と、ドメインが同一でポートが異なるhttpsバーチャルホストから侵入される可能性を記述している。ここから、`Host`ヘッダについてポート番号の整合性をチェックする実装があってもおかしくないし、そうするべきだろう。[^portval]

[^portval]: 自作のnginxモジュール[ngx-strict-sni](https://github.com/JyJyJcr/ngx-strict-sni)はそういう実装になっている。

従って、ポートに関する整合性が走るサーバでは`Host`ヘッダに含まれるポート番号も正しく記述する必要がある。

#### 回答A: 泥臭く

というわけで、`Host`ヘッダを書き換えて

```sh
curl --resolve secret.example.com:4444:10.0.0.10 -H "Host: secret.example.com:8080" http://secret.example.com:4444
```

とすれば良い。

しかしあまり気分の良い方法ではない。

##### 問題点1: `Host`ヘッダの書き換え

[準備問題1](#準備問題1-httpサーバ)でもそうだが、httpの仕様の根幹にある`Host`ヘッダを手で書き換えるというのは行儀が良くない。`Host`ヘッダはできれば`curl`に勝手に生成して欲しいものである。経由ポートでしかない`4444`が、`curl`のhttpモジュールに使われてしまっているのが原因だが、これを防ぐにはURLのホスト部が`secret.example.com:8080`にしないといけない。

##### 問題点2: URLの書き換え

[準備問題2](#準備問題2-受付ポートとhostヘッダの不一致を無視するhttpsサーバ)でもそうだが、URLを`https://secret.example.com:4444`としてしまっている。[問題点1](#問題点1-hostヘッダの書き換え)で指摘した通りhttpモジュールにポートを誤認させられるならその方が良いし、そもそもURL`https://secret.example.com:8080`で指定されるエンドポイントに、`10.0.0.10:4444`経由で繋がりたいという欲求なのだから、プロキシを指定するように書けるべきだ。つまり、`--resolve`オプションがポートも変換できれば良いのだが、そんなオプションがあるだろうか。

#### 回答B: `--connect-to`を使う

以下`curl(1)`より、`--connect-to`オプションの説明。

>For a request to the given "HOST1:PORT1" pair, connect to "HOST2:PORT2" instead. This option is suitable to direct requests at a specific server, e.g. at a specific cluster node in a cluster of servers. This option is only used to establish the network connection. It does NOT affect the hostname/port that is used for TLS/SSL (e.g. SNI, certificate verification) or for the application protocols. "HOST1" and "PORT1" may be the empty string, meaning "any host/port". "HOST2" and "PORT2" may also be the empty string, meaning "use the request's original host/port".
>
>A hostname specified to this option is compared as a string, so it needs to match the name used in request URL. It can be either numerical such as "127.0.0.1" or the full host name such as "example\.org".
>
>--connect-to can be used several times in a command line
>
>Example:
>curl --connect-to example\.com:443:example.net:8443 https:\//example.com
>
>See also --resolve and -H, --header.

というわけで、顧客が本当に必要だったものはちゃんと存在している:

```sh
curl --connect-to secret.example.com:8080:10.0.0.10:4444" http://secret.example.com:8080
```

URLは書き換わらず、`10.0.0.10:4444`をプロキシのように使うという点まで明白になっている。また、[準備問題1](#準備問題1-httpサーバ)・[準備問題2](#準備問題2-受付ポートとhostヘッダの不一致を無視するhttpsサーバ)でもこの方法は有効である。

さらに嬉しいことに、`10.0.0.10`の部分はドメインでも良い。元々考えていた状況はPPPoEとIPoEの2本立てで、PPPoEのIPについてドメインに登録しているという状況だったから、IPoEのIPを登録することもできるはずだし、明らかにその方が便利だ。ここでは`ipoe.example.com`にAレコード`10.0.0.10`を登録してあるとする。`--resolve`では変換先として生のIPアドレスしか指定できないので、別のツールでドメイン解決をする手間があった。`--connect-to`ならドメインで指定すればよいので、ワンライナーになる[^dom]:

```sh
curl --connect-to secret.example.com:8080:ipoe.example.com:4444 http://secret.example.com:8080
```

[^dom]: これは単に記述が簡便に済むという点以外にも、ドメイン解決ツール(例えば`dig`)と`curl`のドメイン解決が異なる実装を採用している場合、本来疎通しない状況で疎通してしまうという問題を解決する。これが問題となるのはhttpsサーバをTCPレベルの疎通確認として使っている場合である。例えば`ssh`に利用可能な最も高速な経路を使わせるために`Match exec`で各種ルートでの疎通確認をやっている時、`ssh`と同一のドメイン解決手段で確認しないと、疎通確認は成功したのに`ssh`はドメイン解決で詰まる、みたいなことが起きる。しかしこれは有効な事例があまりにも狭いので脚注とした。

## まとめ

- `421 Misdirected Request`はポートフォワードと相性が悪い
- `--resolve`よりも`--connect-to`の方が自由度が高くて便利
- `manpage`は目を通しておくべき