🍡

HTTP プロキシを実装して理解する CONNECT メソッド

2025/01/10に公開

はじめに

HTTP Proxy を経由して HTTPS など HTTP 以外のプロトコルで通信を行いたいとき、HTTP の CONNECT メソッドを使ってプロキシ・サーバ間のトンネルを確立し、それを通じてデータをやりとりするということは多くの人が知っていると思います。
実際、わかりやすく図を交えてプロキシと CONNECT メソッドについて解説された記事は数多く存在します。
https://milestone-of-se.nesuke.com/nw-basic/grasp-nw/proxy/

しかし、実際に HTTP プロキシを経由してサーバと HTTPS 通信をするようなクライアントを書きたくなったり、CONNECT メソッドに対応したプロキシを実装してみたくなったとき、例えば以下のような疑問が出てきます。

  • プロキシが接続先サーバのアドレスを見るためには、クライアントとプロキシが平文で通信する必要があり、結果としてセキュリティが低下するのでは?
  • プロキシがサーバとの TLS 通信を終端してしまうなら、クライアントから見ると、接続先のホスト名と証明書のホスト名が異なるから証明書エラーになってしまうのでは?
  • そもそもアプリケーションが TLS 経由で通信する時点で全てのデータが暗号されているのだから、プロキシはどうやって接続先のアドレスを知るんだ?

これらの疑問に対する回答は次の通りです。

  • クライアントとプロキシが平文で通信するのは、最初の CONNECT リクエストのみ。それ以降のデータは、TCP でクライアント・プロキシ間の通信路を通る。TLS で通信する場合はその TCP コネクションを使うので、アプリケーションデータはクライアントからサーバまで暗号化された状態で送られる。
  • プロキシはサーバとの TLS 通信を終端しない。終端するのはあくまでクライアントだから、TLS のレイヤでは通常通り証明書を検証するだけでよい。
  • プロキシに対応したクライアントは、アプリケーションデータを送る前に CONNECT リクエストだけを平文でプロキシに送るので、プロキシは問題なく接続先を読める。

このように回答はできるものの、クライアントとプロキシの間でやり取りされるデータやプロキシの挙動がよくわかってないと、なかなかイメージが掴めないと思います。
そこでこの記事では、最初に curl クライアントがプロキシを経由して HTTPS リクエストを送るときに、どのようなデータが送られるのかを調べます。
その後、CONNECT メソッドに対応した HTTP プロキシを実装しながら、どのようにしてサーバとのトンネルが確立され、その後の通信が行われるのかを見ていきます。

また、これ以降、プロキシという言葉は HTTP プロキシを指すこととします。

筆者の環境

Macbook Pro(Apple M3 Pro)
macOS 14.5
go version go1.23.2 darwin/arm64

クライアントはどのようにプロキシと通信するのか

プロキシがないとき

まずは、プロキシを経由せず HTTP で通信を行ったときに、クライアントがどのようなリクエストを送っているのかを調べます。

curl -v --http1.1 http://google.com
...
* Connected to google.com (142.251.42.142) port 80
> GET / HTTP/1.1
> Host: google.com
> User-Agent: curl/8.6.0
> Accept: */*
> 
...

いわゆる普通の、つまり GET / HTTP/1.1 が先頭に書かれていて、その後にヘッダとボディが続くよくある HTTP リクエストが送られています。
次に、HTTPS ではどうでしょうか。

curl -v --http1.1 https://google.com
...
* Connected to google.com (142.251.42.142) port 443
...
* SSL connection using TLSv1.3 / AEAD-CHACHA20-POLY1305-SHA256 / [blank] / UNDEF
...
> GET / HTTP/1.1
> Host: google.com
> User-Agent: curl/8.6.0
> Accept: */*
...

80 番ポートではなく 443 番ポートに接続していることと、TLS で接続していることを除けば、HTTP リクエストとしては全く同じものが送られています。

プロキシがあるとき

プロキシサーバの代わりとして、nc を使った簡単な TCP サーバを起動しておきます。

nc -l 10800

別の端末で、プロキシを指定して HTTP リクエストを送ってみます。

curl -v --http1.1 --proxy 127.0.0.1:10800 http://google.com
*   Trying 127.0.0.1:10800...
* Connected to 127.0.0.1 (127.0.0.1) port 10800
> GET http://google.com/ HTTP/1.1
> Host: google.com
> User-Agent: curl/8.6.0
> Accept: */*
> Proxy-Connection: Keep-Alive
> 

プロキシがないときと比較して、いくつかの違いがあることがわかります。

  • google.com ではなく 127.0.0.1 に接続している
  • URL が / ではなく http://google.com/ になっている
  • Proxy-Connection: Keep-Alive の行が増えている

次に、HTTPS も試してみます。

curl -v --http1.1 --proxy 127.0.0.1:10800 https://google.com
*   Trying 127.0.0.1:10800...
* Connected to 127.0.0.1 (127.0.0.1) port 10800
* CONNECT tunnel: HTTP/1.1 negotiated
* allocate connect buffer
* Establish HTTP proxy tunnel to google.com:443
> CONNECT google.com:443 HTTP/1.1
> Host: google.com:443
> User-Agent: curl/8.6.0
> Proxy-Connection: Keep-Alive
> 

CONNECT メソッドが現れましたね!
ここで注意したいのは、curl による出力に TLS に関する項目が出ていないということです。実際、nc で起動しているのは単なる TCP のサーバであり、TLS のサーバにはなりえません。このことから、CONNECT リクエストは平文でプロキシに送られていることがわかります。

実験結果をまとめると、以下のようになります。

  • クライアントがサーバと HTTP 通信をする場合、クライアントはプロキシに TCP で接続し、HTTP リクエストにおける URL にはホスト名を含める。
  • クライアントがサーバと HTTPS 通信をする場合、クライアントはプロキシに TCP で接続し、サーバのFQDN:ポート番号を HTTP リクエストの URL に指定して平文で CONNECT リクエストを送る。

プロキシに対応したクライアントの動作

以上で得られた情報から、プロキシに対応したクライアントは、次のような動作をすると推測できます。

  • プロキシを経由するオプションが設定されていない場合、直接接続したいサーバに接続する。
  • プロキシを経由するオプションが設定されている場合、
    • サーバと HTTP で通信したい場合、接続したいサーバのホスト名を URL に含めた上で HTTP リクエストをプロキシに送る。
    • それ以外のプロトコルで通信したい場合、接続したいサーバのFQDN:ポート番号を URL に指定して HTTP の CONNECT リクエストをプロキシに送る。プロキシから Status 200 が返ってきたら、プロキシに向けて CONNECT リクエストを送ったのと同じ TCP コネクションを使ってアプリケーションデータを送る。

CONNECT メソッドを使えば、プロキシはそれ以降単なる TCP プロキシとして動作するので、TLS や HTTPS に限らず任意の(TCP 上の)プロトコルでサーバと通信できるのがポイントです。

CONNECT メソッドに対応したプロキシの実装

動作

上で書いたようなクライアントに対応するプロキシの動作をイメージすると、次のようになります。

  • TCP サーバを起動する。
  • クライアントから HTTP リクエストを受け取る。
    • もしメソッドが CONNECT 以外の場合、
      • URL に書かれたサーバに接続し、URL からホスト名を取り除いた HTTP リクエストをサーバに送る。
      • 例えば、クライアントからのリクエストが GET http://google.com/ HTTP/1.1 であれば、http://google.com に接続し、GET / HTTP/1.1 で始まる HTTP リクエストを送る。
      • サーバからのレスポンスをクライアントにそのまま送る。
    • もしメソッドが CONNECT の場合、
      • URL に書かれたホスト名に TCP で接続する。
      • サーバとの接続に成功したら、クライアントに Status 200 を返す。
      • クライアントから受け取ったデータをそのまま TCP でサーバに送る。
      • サーバからのレスポンスをクライアントにそのまま送る。

実装

Go 言語を使って、上で書いたように動作するプロキシを実装しました。
ただし、今回はあくまで CONNECT メソッドがメインなので、サーバとの HTTP 通信はできません(HTTP プロキシとは?)。
https://gist.github.com/arailly/761413144cc62584e6ea57dd8590c6de

抜粋を以下に示します。まずは main 関数です。
main 関数では、TCP サーバを起動し、クライアントからの接続があると handleConnection 関数でデータをやり取りします。

func main() {
	// TCP サーバを起動
	listener, err := net.Listen("tcp", ":10800")
	if err != nil {
		fmt.Println("Error starting TCP server:", err)
		return
	}
	defer listener.Close()
	fmt.Println("TCP server listening on port 10800")

	for {
		// クライアントからの接続を待機
		conn, err := listener.Accept()
		if err != nil {
			fmt.Println("Error accepting connection:", err)
			continue
		}
		// クライアントごとに新しいゴルーチンを開始
		go handleConnection(conn)
	}
}

handleConnection 関数の冒頭は以下の通りで、クライアントから送られてきた TCP のペイロードを受け取って HTTP のリクエストに変換しています。

func handleConnection(clientConn net.Conn) {
	defer clientConn.Close()
	fmt.Println("Client connected:", clientConn.RemoteAddr().String())

	// クライアントからの HTTP リクエストを受け取る
	reader := bufio.NewReader(clientConn)
	req, err := http.ReadRequest(reader)
	if err != nil {
		fmt.Println("Error reading request:", err)
		return
	}

	fmt.Println("Request received!")
...

次に、HTTP リクエストのメソッドが CONNECT かどうかを判定します。
CONNECT であれば、リクエストの URL に記載されたサーバに接続し、接続に成功したらクライアントに 200 を返します。

	switch req.Method {
	case http.MethodConnect:
		// サーバに接続
		fmt.Println("Connect to server: ", req.URL.Host)
		serverConn, err := net.Dial("tcp", req.URL.Host)
		if err != nil {
			fmt.Println("Error connecting to server:", err)
			return
		}
		defer serverConn.Close()

		// クライアントに 200 を送信
		response := &http.Response{
			StatusCode: http.StatusOK,
			ProtoMajor: 1,
			ProtoMinor: 1,
		}
		if err := response.Write(clientConn); err != nil {
			fmt.Println("Error writing response:", err)
			return
		}

次に、io.Copy を使ってクライアントからのデータをサーバに、サーバからのデータをクライアントに転送します。
ここで、io.Copy は第二引数の Reader から EOF が返るかエラーが起きるまで処理をブロックします。
つまり、io.Copy が終了したということはコネクションが切られたかエラーが起きたかのどちらかということになるので、どちらか一方でも io.Copy が終了したら両方のコネクションを切るようにしています。

		isClosed := make(chan bool, 2)

		// client -> server
		go func() {
			_, err := io.Copy(serverConn, clientConn)
			if err != nil {
				fmt.Println("Failed to copy data from client to server", err)
			}
			isClosed <- true
		}()

		// server -> client
		go func() {
			_, err := io.Copy(clientConn, serverConn)
			if err != nil {
				fmt.Println("Failed to copy data from server to client", err)
			}
			isClosed <- true
		}()

		// どちらかのゴルーチンが終了するまで待機
		<-isClosed

		// どちらかが終了したら handleConnection 関数は終了する
		// その際、defer によって両方のコネクションが閉じられる

	default:
		fmt.Println("Unsupported method:", req.Method)
	}
}

動作確認

先ほどのプログラムを起動します。

❯ go run main.go
TCP server listening on port 10800

次に、curl で https://google.com にアクセスすると、出力の冒頭は以下のようになります。

curl -v --http1.1 --proxy http://127.0.0.1:10800/ ht
tps://google.com
*   Trying 127.0.0.1:10800...
* Connected to 127.0.0.1 (127.0.0.1) port 10800
* CONNECT tunnel: HTTP/1.1 negotiated
* allocate connect buffer
* Establish HTTP proxy tunnel to google.com:443
> CONNECT google.com:443 HTTP/1.1
> Host: google.com:443
> User-Agent: curl/8.6.0
> Proxy-Connection: Keep-Alive
>

ここまでは nc -l で TCP サーバを起動したときと同じ出力です。これ以降を見てみると、

< HTTP/1.1 200 OK
< Content-Length: 0
* Ignoring Content-Length in CONNECT 200 response
< 
* CONNECT phase completed
* CONNECT tunnel established, response 200

< HTTP/1.1 200 OK と出ているので、プロキシがサーバに接続して成功し、その結果をクライアントに送れていることがわかります。

https://gist.github.com/arailly/761413144cc62584e6ea57dd8590c6de#file-http-proxy-with-connect-go-L58-L67

続いて、

...
* SSL connection using TLSv1.3 / AEAD-CHACHA20-POLY1305-SHA256 / [blank] / UNDEF
...
> GET / HTTP/1.1
> Host: google.com
> User-Agent: curl/8.6.0
> Accept: */*
> 
< HTTP/1.1 301 Moved Permanently
< Location: https://www.google.com/
< Content-Type: text/html; charset=UTF-8
< Content-Security-Policy-Report-Only: object-src 'none';base-uri 'self';script-src 'nonce-xXk0mHtA06iAEYV5dZMg1g' 'strict-dynamic' 'report-sample' 'unsafe-eval' 'unsafe-inline' https: http:;report-uri https://csp.withgoogle.com/csp/gws/other-hp
< Date: Thu, 09 Jan 2025 09:31:43 GMT
< Expires: Sat, 08 Feb 2025 09:31:43 GMT
< Cache-Control: public, max-age=2592000
< Server: gws
< Content-Length: 220
< X-XSS-Protection: 0
< X-Frame-Options: SAMEORIGIN
< Alt-Svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000
< 
<HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">
<TITLE>301 Moved</TITLE></HEAD><BODY>
<H1>301 Moved</H1>
The document has moved
<A HREF="https://www.google.com/">here</A>.
</BODY></HTML>
* Connection #0 to host 127.0.0.1 left intact

TLS のハンドシェイクが正常に完了し、無事に HTTP リクエスト・レスポンスを送受信できています!

最後に

本記事では、クライアントがプロキシを経由してサーバにデータを送信するとき、実際にどのようなデータが送られるのかを調べました。
そして、CONNECT メソッドに対応した簡単なプロキシを実装し、curl による HTTPS 通信がクライアント -> 自作プロキシ -> サーバの経路で行えることを確認しました。
CONNECT メソッドを使ったプロキシ経由の通信についてよくわからなくなったら、ぜひ本記事を見返して理解を深めていただけると幸いです。

Discussion