🤔

Echoのリバースプロキシを使った時にハマる点

2024/05/18に公開

概要

GoのWebフレームワークであるEchoを使ってリバースプロキシを実装する際にハマりました。一旦エラーを回避できる方法を見つけたので記事として残しておこうと思います。ただこれ以外に方法がないか気になるので知見あるかたいたら教えて欲しいです。

実装

二つのAPIがあり、リクエストを受けるAPIをapi-a、プロキシ先をapi-bとします。実装したかった機能は、api-aへのリクエストパスが"/b"を含む場合、そのリクエストをapi-bへ流すようなミドルウェアです。
ex. "https://a.com/b/users" -> "https://b.com/uses"

Echoの公式ドキュメント(https://echo.labstack.com/docs/cookbook/reverse-proxy )には、リバースプロキシを実装するためのサンプルコードが記載されてるためそれを参考に実装を行いました
サンプルコード

url1, err := url.Parse("http://localhost:8081")
if err != nil {
  e.Logger.Fatal(err)
}
url2, err := url.Parse("http://localhost:8082")
if err != nil {
  e.Logger.Fatal(err)
}
targets := []*middleware.ProxyTarget{
  {
    URL: url1,
  },
  {
    URL: url2,
  },
}

実装したもの

func customProxy(next echo.HandlerFunc) echo.HandlerFunc {
    return func(c echo.Context) error {
        target, err := url.Parse("ターゲットのURL")
        if err != nil {
            log.Println("failed to parse target url")
            return c.NoContent(http.StatusInternalServerError)
        }

        return middleware.ProxyWithConfig(middleware.ProxyConfig{
            Balancer: middleware.NewRoundRobinBalancer([]*middleware.ProxyTarget{
                {
                    URL: target,
                },
            }),
        })(next)(c)
    }
}

事象

しかし、上記のコードでデプロイしたところ、ステータスコード404が返ってきてしまいました。api-bのログを確認したところ、ログが更新されておらずそもそもリクエストがapi-bまで届いていないことがわかりました

原因

1: パスの書き換え

一つ目の問題点は、パスをプロキシ先のAPIのパスに書き換えていなかったことです。
これは完全に自分の誤解が原因でした。そもそもこのミドルウェアを設定するグループを"/b"配下のものに限定していたため、"/b"よりも前のパスは書き換えてくれるものだと勝手に思っていました。

2: Hostヘッダーの書き換え

二つ目の問題点は、リクエストのHostヘッダーがプロキシ先のAPIのホスト名に書き換えられていなかったことです。
これも完全に自分の誤解で、BalancerのURLにtargetを指定すれば、勝手にtargetのhostになると思っていました。
ただしこの変更はプロキシ先のAPIのデプロイ方法に依存されると思われます。この点後述します。

解決法

1に関してはechoのProxyConfigにはRewriteと言うプロパティが存在するので、書き換えルールはここで設定することができます。
2に関しては、echoの中にhostヘッダーを書き換えるような機能を見つけることができなかったのでcontextを直接書き換えるようにしました。

修正後のコード

func customProxy(next echo.HandlerFunc) echo.HandlerFunc {
    return func(c echo.Context) error {
        target, err := url.Parse("ターゲットのURL")
        if err != nil {
            log.Println("failed to parse target url") 
            return c.NoContent(http.StatusInternalServerError)
        }

        req := c.Request()
        req.Host = target.Host

        return middleware.ProxyWithConfig(middleware.ProxyConfig{
            Balancer: middleware.NewRoundRobinBalancer([]*middleware.ProxyTarget{
                {
                    URL: target,
                },
            }),
            Rewrite: map[string]string{
                "/api/b/*": "/$1",  
            },
        })(next)(c)
    }
}

深掘りポイント

上記のコードでエラーは解決しましたが、モヤっとした点あったので深掘りたいです。正しい情報か詳しい方いたら教えてください。

1: Hostヘッダーが不正な値でもエラーにならない場合はある

原因2では、Hostヘッダーの値をプロキシ先のHost名(api-b)に変更する必要があると書きました。この変更を行わないことがなぜ404エラーにつながるのでしょうか。

デプロイ先のサービスが原因?

まず、実は原因1を修正した段階で、ローカル環境ではリバースプロキシが成功していました。ローカルでのdockerを使用した動作確認では、プロキシ先のurlに"host.docker.internal"を使用していたため、おそらくHostヘッダーを確認する処理がなかったためだと思われます。
つまり、プロキシ先の"https://api-a/..."をでプロしているサービス側の処理に原因があるのでしょうか。

CloudRunではHostヘッダーとURLのホスト名の照合を行うのか?

デプロイ先のサービスが原因と仮説ができたので、実際どうなのか調べてました。
今回実装したAPIのデプロイ先はGCPのCloudRunだったため、CloudRunではHostヘッダーとURLのホスト名の照合を行う処理があるのかドキュメント内を調べてみましたが特に関連する記載はありませんでした。

ただ、これを調べていく中でドメインフロンティング攻撃と言うものがあることを知りました。
ドメインフロンティング攻撃は、Hostヘッダーの値と送信先のドメインが異なるリクエストを行うことで、本来不可能なhostへのアクセスを行うものらしいです。
このドメインフロンティング攻撃の検知のために、上記の処理は含む必要があり、エビデンスは見つけられませんでしたがCloudRunもこの脆弱性は持っていないと言うことで一旦自分は納得しました。

2: Echoのリバースプロキシでは複数のHostに振り分けることは可能?

今回のエラー回避のためにhost名を直接書き換えるようにしました。しかしこの方法では、ターゲットが複数で、かつターゲットのhost名が異なる場合に対処できなさそうです。
私の今回の要件では複数ターゲットのプロキシが不要だったため良かったものの、疑問が残ります。
ドキュメントでは複数ターゲットの例がありますが、ポートが違うもののどちらもlocalhostを使用しているのでホスト名が一緒です。
echoではhostヘッダーの書き換えまで行ってくれる機能はないんでしょうか?

まとめ

Echoを使ったリバースプロキシの実装のつまりポイントについてまとめました。ほとんど自分の誤解に基づくフレームワークへの過度な期待が原因でした。もっとドキュメントを読む力をつけたいです。

参考文献

Reverse Proxy - Echo, LabStack
https://echo.labstack.com/docs/cookbook/reverse-proxy

Discussion