ProxyConnectHeaderを使ってhttp.ClientによるCONNECTメソッドにヘッダーを付加する


事象

Go 1.14.2の環境で、認証Proxyを経由して任意のサイトにアクセスするHTTPクライアントを実装していたところ、接続先がHTTPSのときに認証proxyから407が返ってしまいました。

再現コード(main.go)
func main() {
    u, _ := url.Parse("http://127.0.0.1:3128") // ローカルにsquidを立て、認証Proxyとした
    req, err := http.NewRequest("GET", "https://yahoo.co.jp", nil)
    if err != nil {
        log.Fatal(err)
    }
    req.Header.Add("Proxy-Authorization", "Basic <basic auth string>")
    c := &http.Client{
        Transport: &http.Transport{
            Proxy: http.ProxyURL(u),
        },
    }
    resp, err := c.Do(req)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(resp.Status)
}

上記を実行すると、 407 (Proxy Authentication Required) が返って通信ができません。 Proxy-Authorization に然るべき認証情報は正しくセットしているのですが、なぜ通信できないのでしょうか。

$ go run main.go
2020/08/09 16:02:34 Get https://www.yahoo.co.jp/: Proxy Authentication Required
exit status 1

原因

接続先がHTTPSの場合、接続先とのTLSハンドシェイクの前に、仲介者である認証Proxyに対して CONNECT メソッドが発行されます。再現コードでヘッダーとして付与していた Proxy-Authorization が、このCONNECTメソッド実行時に付与されなかったため、本事象が発生していました。

CONNECTの実行は、内部的には http.TransportdialConn メソッドにて実行されています。このメソッドでは、CONNECTを行うために http.Request を新たに生成していますが、ここではもともと再現コードでProxy-AuthorizationをセットしたHeaderを使いません。その代わり、 http.TransportProxyConnectHeader という項目の値をセットしています。

http.Transport#dialConnより抜粋(transport.go#L1564-L1579)
    case cm.targetScheme == "https":
        conn := pconn.conn
        hdr := t.ProxyConnectHeader
        if hdr == nil {
            hdr = make(Header)
        }
        if pa := cm.proxyAuth(); pa != "" {
            hdr = hdr.Clone()
            hdr.Set("Proxy-Authorization", pa)
        }
        connectReq := &Request{
            Method: "CONNECT",
            URL:    &url.URL{Opaque: cm.targetAddr},
            Host:   cm.targetAddr,
            Header: hdr,
        }

このCONNECT時に元のHeaderを使わない仕様、私は最初ちょっと意外に感じたのですが、考えてみれば、元々の要求でセットしていたHeaderはあくまで本来の接続先のために用意しているものなので、認証Proxyとの会話であるCONNECT時にそれを渡してはならないですね。不要ですし、何よりセキュリティの観点から望ましくないです。

ProxyConnectHeader は、当然ながら http.Transport のコメントでも説明されています。


    // ProxyConnectHeader optionally specifies headers to send to
    // proxies during CONNECT requests.
    ProxyConnectHeader Header // Go 1.8

対応

http.Request.Header にセットしていた値を、 ProxyConnectHeader に入れてあげるよう変更すれば、事象の解消が可能です。

main.go
func main() {
    u, _ := url.Parse("http://127.0.0.1:3128")
    req, err := http.NewRequest("GET", "https://yahoo.co.jp", nil)
    if err != nil {
        log.Fatal(err)
    }
    hdr := make(http.Header)
    hdr.Add("Proxy-Authorization", "Basic <basic auth string>")
    c := &http.Client{
        Transport: &http.Transport{
            Proxy:              http.ProxyURL(u),
            ProxyConnectHeader: hdr,
        },
    }
    resp, err := c.Do(req)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(resp.Status)
}

いけましたね。

$ go run main.go
200 OK

また、Proxy-Authorization に固定の認証情報をセットしたいだけなら、ProxyのURLに入れても良いですね。

func main() {
    u, _ := url.Parse("http://<username>:<password>@127.0.0.1:3128") // 認証情報を付加
    req, err := http.NewRequest("GET", "https://yahoo.co.jp", nil)
    if err != nil {
        log.Fatal(err)
    }
    c := &http.Client{
        Transport: &http.Transport{
            Proxy: http.ProxyURL(u),
        },
    }
    resp, err := c.Do(req)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(resp.Status)
}
$ go run main.go
200 OK

おわり。