FastAPIでX-Forwarded-Hostを用いたリダイレクト

ソースを見てみたい人向け

https://gist.github.com/attakei/6b308a2a7949746a8027fc0258f1de1c

なにこれ

FastAPI 用に作ったミドルウェアで、リクエスト時に X-Forwarded-Host ヘッダーが付与されていた場合に、 その値を Host ヘッダーに上書きするものです。 FastAPI 用とはいっても、依存してるのは Starlette だけなので、 Starlette 派生なら普通に動くと思います。

何ができるの?

FastAPI が動いているサーバーのホスト名と、サービスのFQDNが違っているケースで、 いわゆる Slashes redirection が有効になっている際に、 そのリダイレクトもサービスのFQDNで行われるようになります。

コードを書いてみた背景とか

Firebase + Cloud Run の挙動

このサイトは現在実験を兼ねて、 Firebase Hosting + Cloud Run の組み合わせで動作していて、 一部のURLは バックエンド設定した Cloud Run サービスからレスポンスを返すようにしています。

Firebase+CloudRunのリクエストの流れ

Firebase+CloudRunのリクエストの流れ

Cloud Run はDockerコンテナを使って「サービス」を提供する機能のため、ホスト名を持っています。 Firebase から Cloud Run への通信も最終的にはこのホスト名が使われ、 Firebase のホスト名自体は、 X-Forwarded-Host ヘッダー経由で渡るようになっています。

ブラウザ-Firebase間のヘッダー(一部)

Firebase-CloudRun間のヘッダー(一部)

FastAPIの Slashes redirection

FastAPI のルーティングは、 APIRouter クラスが担っています。 そして、 APIRouter のプロパティには redirect_slashes があります。

これは、 プロパティが True なら「末尾が / となるルーティング設定」がすでに存在する際に、 / 末尾が足りないリクエストが来た場合に、 / スラッシュを付与したURLへリダイレクトさせる機能です。

FastAPIとブラウザのやり取り例

このとき、内部ではリダイレクト先のURLをスキーマから全部構築した状態で、用意します。 そして、その時の参照状情報として Host ヘッダーを利用しています。

組み合わさるとどうなるか

上記2点が組み合わさると、 Cloud Runの視点では Host ヘッダーはあくまで Cloud Run 自身のサービスFQDNとなるため、

「Firebaseのホスト名でredirect-slashesの条件を満たすと、Cloud Runのホスト名のURLでリダイレクト指示を出す」

という状況が発生してしまいます。

実際のリクエスト

「なんのためのFirebaseなのか」といった感じですね。

中身的には、こんな感じです。

# flake8: noqa


class Router:
    """starlette.routing.Router から必要最低限のとこだけ抜粋"""

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        # 前略

        if scope["type"] == "http" and self.redirect_slashes and scope["path"] != "/":
            # リクエストパスが / で終わらない時に、リダイレクト可能性があるので / 付きのデータを用意
            redirect_scope = dict(scope)
            if scope["path"].endswith("/"):
                redirect_scope["path"] = redirect_scope["path"].rstrip("/")
            else:
                redirect_scope["path"] = redirect_scope["path"] + "/"

            # ルーティング情報からリダイレクト先とマッチする情報があるか探し、
            # 見つかったら、リダイレクトレスポンスを返してしまうs
            for route in self.routes:
                match, child_scope = route.matches(redirect_scope)
                if match != Match.NONE:
                    redirect_url = URL(scope=redirect_scope)  # <= この中身がhostしかみない
                    response = RedirectResponse(url=str(redirect_url))
                    await response(scope, receive, send)
                    return
        # 後略

どう対処したか

https://gist.github.com/attakei/6b308a2a7949746a8027fc0258f1de1c

ミドルウェアを用意してリクエスト処理に割り込みを行い、 「 X-Forwarded-Host ヘッダーがあった時に限り、 Host ヘッダーの中身を上書きする」 ようにしました。

 1class ForwardedHostMiddleware:
 2    def remap_headers(self, src: Headers, before: bytes, after: bytes) -> Headers:
 3        """元のヘッダーリストから、
 4        - 無関係なものはそのままコピーして
 5        - 置換対象は一旦退避して
 6        - 結果に応じて再追記する
 7        """
 8        remapped = []
 9        before_value = None
10        after_value = None
11        for header in src:
12            k, v = header
13            if k == before:
14                before_value = v
15                continue
16            elif k == after:
17                after_value = v
18                continue
19            remapped.append(header)
20        if after_value:
21            remapped.append((before, after_value))
22        elif before_value:
23            remapped.append((before, before_value))
24        return remapped

弊害はあるか?

他に Host ヘッダを元に何かを識別しているケースがあると、もしかしたら想定外の挙動をするかもしれません。

ただ自分の場合だと、アプリケーションの中身自体は極力 Host ヘッダーに依存しない用に作っているので、 きっと害は無いと信じてます。