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

なにこれ

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

何ができるの?

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

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

Firebase + Cloud Run の挙動

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

blockdiag Browser Firebase Hosting Cloud Run

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

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

blockdiag Browser Host: example.web.app Firebase Hosting

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

blockdiag Firebase Hosting Host: example.a.run.app X-Forwarded-Host: example.web.app Cloud Run

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

FastAPIの Slashes redirection

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

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

blockdiag browser webserver GET /api/data Location: /api/data/ GET /api/data/

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

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

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

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

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

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

blockdiag browser firebase cloudrun GET /api/data GET /api/data Location: example.a.run.app/api/data/ Location: example.a.run.app/api/data/ GET /api/data/

実際のリクエスト

「なんのための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 ヘッダーの中身を上書きする」 ようにしました。

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

弊害はあるか?

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

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