コンテンツにスキップ

リバースプロキシの後ろに置く

nginx なり Ingress コントローラなりクラウドのロードバランサなりをアプリケーションの 手前に置いた瞬間、プロセスの通信相手はブラウザではなくなります。プロキシです。 訪問者が HTTPS で送ったリクエストでも r.TLS は nil で、r.RemoteAddr はクライアントで はなくプロキシのアドレスで、接続から分かっていたことはすべてヘッダに移ります。つまり、 リクエストを送った者が名乗った主張に変わります。

フレームワークはその主張をほとんど信じません。公開ポートで待ち受けるプロセスの既定と しては正しく、そしてプロキシの後ろでは 3 箇所で具合が悪くなります。HSTS が出てこない。 フォームの送信が全部拒否される。逐次描画が逐次でなくなる。

手前に何も無いなら——プロセス自身が TLS を終端している、あるいは自分の持つソケットから しか到達できない——ここの設定は不要です。server.trusted_proxies は空のままにして ください。X-Forwarded-Proto を詐称不能にしているのは、その空のリストです。完全には 制御できないネットワークを書けば、その内側からポートに届く誰にでもこのヘッダを渡す ことになります。

逆向きの配置——アプリケーションが一部のパスを、ブラウザから直接は届かないサービスへ 転送する——は別の仕事で、ページも別です。バックエンドサービスへのプロキシを 参照してください。

2 つのファイルが噛み合っている必要があります。アプリケーション側は次のとおりです。

[server]
port = 8080
trusted_proxies = ["127.0.0.1"] # プロキシがどこから接続してくるか
[security.headers.hsts]
enabled = true
max_age = "8760h" # 1 年。duration に日の単位は無い
[security.csrf]
enabled = true
trusted_origins = ["https://app.example.com"] # ブラウザに見えているオリジン

同じホストに nginx を置く場合、プロキシ側はこうなります。

server {
listen 443 ssl;
server_name app.example.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_http_version 1.1;
proxy_buffering off;
proxy_read_timeout 15m;
}
}

この location ブロックはどの行も何かを支えています。以降は、それぞれが何を支えて いるかの話です。

今のところ 1 つだけです。セキュリティヘッダのミドルウェアが「このリクエストは HTTPS で 到着したか」を問うとき、X-Forwarded-Proto を信じるかどうか。HSTS は HTTPS と確認できた リクエストにしか送られず、TLS を終端するプロキシの後ろには確認すべき TLS 接続がありません。 設定した Strict-Transport-Security と、いつまでも出てこないヘッダのあいだに立っているのが このリストです。HSTS を設定し、プロキシの後ろにデプロイし、ヘッダが出ないのを眺める—— その症状がこれで、直すのはこのリストです。

効き目はそこまでです。フレームワークはクライアント IP を復元しませんし、他の転送系 ヘッダは製品ビルドのどこからも読まれません。

ヘッダ 読んでいるもの
X-Forwarded-Proto セキュリティヘッダのミドルウェア(このリストで門番される)。および plugin/auth が post-logout リダイレクト URL を組み立てるとき(門番なし)
Host CSRF のオリジン照合と、フレームワークが組み立てる絶対 URL のすべて
X-Forwarded-ForX-Forwarded-HostForwardedX-Real-IP 開発時以外は何も読んでいない

1 行目の門番なしの読み取りがあるので、プロキシは X-Forwarded-Proto上書きしな ければなりません。届いた値に追記してはいけません。nginx の $scheme は上書きします。 クライアントの送った値を残す書き方をすると、スキームをクライアントが名乗れます。

値は IP アドレスか CIDR ブロックで、不正な値は無視されるのではなく起動に失敗します。 真であるもののうち最も狭いものを書いてください。同一ホストのプロキシなら 127.0.0.1、 サイドカーや Ingress ならその Pod ないしサブネットの CIDR です。

プロキシのアドレスが固定でないとき——プールから入れ替わる ALB、CDN、そもそも範囲を 教えてくれないプラットフォーム——たいていは、アプリケーションから HSTS を送るのをやめて ロードバランサに送らせるほうが良い答えです。TLS 接続を終端しているのはその層で、 アプリケーションが推測しようとしている答えを最初から知っています。1 つの層がヘッダを 設定するほうが、2 つの層が食い違うよりましです。trusted_proxies = ["0.0.0.0/0"] に この設定を使うのは、そのロードバランサー以外からポートへ絶対に到達できない場合だけにしてください。 書くなら、ネットワークだけが境界だと宣言していることを承知の上で書くことになります。

オリジン照合には別のリストがある

Section titled “オリジン照合には別のリストがある”

こちらは謎として現れます。localhost では動いていたアプリケーションをプロキシの後ろに 置いた途端、フォームの送信がすべて 403 で返り、レスポンスは理由を何も言いません。

CSRF のチェックは、ブラウザの Origin と、プロセスが自分自身について再構成したオリジンをスキーム込みで比較します。 そしてそのスキームは r.TLS から決めています。終端プロキシの後ろにはそれが無いので、 プロセスは自分を http://app.example.com だと判断し、ブラウザは https://app.example.com だと告げています。ホストは同じ、スキームは別、一致しません。

X-Forwarded-Proto を読んでこれを繕う処理はどこにもありません。見落としではなく意図的な 不在です。再構成したオリジンは比較の基準そのもので、ヘッダを名乗れる呼び出し元は 答えのほうを名乗れてしまいます。だから、デプロイ側が自分の公開オリジンを申告します。

[security.csrf]
trusted_origins = ["https://app.example.com"]

ブラウザが正当に表示しうるオリジンをすべて挙げてください。apex と www の両方が 応答するならその両方を、環境ごとのホスト名はその環境のファイルに。比較されるのは スキームとホストだけなので、末尾のスラッシュやブラウザからコピーしたパスは正規化されて 落ちます。スキームを含まない値のほうは、保持されずに捨てられます。

Host はそのまま届かなければならない

Section titled “Host はそのまま届かなければならない”

その比較のもう半分が r.Host です。OIDC の post-logout リダイレクトもここから 組み立てられます。nginx の proxy_pass は、指示しない限り Host ヘッダを上流の アドレスに差し替えます。再構成されるオリジンが http://127.0.0.1:8080 になり、両方が 壊れます。proxy_set_header Host $host が防いでいるのはこれです。

ホスト名を決めるついでに、もう 1 つ。アプリケーションはホスト名の根を 1 つ持つ必要が あります。ブラウザランタイムは /_pw/ という固定の絶対プレフィクスから配信され、これを 動かす設定はありません。location /app/ でプレフィクスを剥がすと、ページの script タグ が指すパスをプロキシがルーティングできなくなります。アプリケーションにはホスト名を 与えるか、書き換えずに届くパスを与えてください。

バッファリングは逐次描画を殺す

Section titled “バッファリングは逐次描画を殺す”

await 境界を持つページは、まずシェルを確定 させ、各領域を確定した順に送ります。ライブ描画 は 1 つのレスポンスを数分間開いたままにします。レスポンスをバッファリングするプロキシは それを全部溜め込み、最後にまとめて渡します。ページ自体は正しいままです。ただ、その仕組みが 無かった場合と同じ届き方をします。

フレームワークは X-Accel-Buffering: no を送らないので、レスポンス側からプロキシの既定を 覆すものはありません。バッファリングは設定のある側で切ってください。nginx なら proxy_buffering off です。あわせて proxy_http_version 1.1 を指定します。上流の レスポンスがそもそもチャンク転送できるようにするためです。

制御下に無いプロキシが相手なら、逃げ道は「相手が扱いを誤るレスポンスを作るのをやめる」 ことです。

[html]
streaming = false

これはライブ描画も止めます。ライブ配信は、バッファされた描画が書かないプレースホルダを ドキュメントが持っていることに依存しているからです。ドキュメントは有効かつ完全なまま 残ります。最初の 1 バイトが出る前に確定するだけです。

もう半分はタイムアウトです。live_max_duration の既定は 10 分なので、プロキシの read タイムアウトがそれを下回ると健全な接続が閉じられ、クライアントは再接続して同じ壁に ぶつかります。上の 15m のようにプロキシ側をそれより上げるか、与えられたタイムアウトに 収まるよう live_max_duration を下げてください。

ひとつのアドレスに、匿名の訪問者が全員

Section titled “ひとつのアドレスに、匿名の訪問者が全員”

html.live_max_responses はクライアントごとの同時ライブレスポンス数に蓋をします。 そのクライアントを識別しているのは、認証済みならサブジェクト、そうでなければ RemoteAddr です。プロキシの後ろでは匿名の訪問者が 1 つのアドレスを共有します。 ブラウザ 1 つあたり 4 本という蓋が、デプロイ全体で 4 本という蓋に変わり、公開ダッシュ ボードを見ようとした 5 人目が拒否されます。

どちらの直し方になるかは、見ているのが誰かで決まります。ログインの内側にあるライブ ページはサブジェクトで識別されているので、何も変える必要がありません。公開されている ページのほうは蓋を移す必要があります。

[html]
live_max_responses = 0 # ここでは無制限にする

0 は蓋を外します。安全なのは他の何かが接続数を制限している場合だけで、それができる層は プロキシです。個々のクライアントがまだ見えているのはそちらだからです。

pw dev/_pw/test/ 以下のテスト用エンドポイントと、開発用 JWT の緩和は、同じ マシンからの呼び出し元しか通しません。そして転送系ヘッダがどれか 1 つでも付いていれば、 呼び出し元は中継だと判断します。opt-out はありませんし、設定ミスでもありません。nginx や docker run -p がホストに居座った時点で、世界中のリクエストが 127.0.0.1 から到着し、 アドレスはクライアントを説明しなくなります。2 つの場合を区別できる唯一の痕跡が転送系 ヘッダです。pw dev をプロキシの後ろで動かすとこれらのエンドポイントは失われますが、 それが狙いです。

プロキシがすでにやっていること

Section titled “プロキシがすでにやっていること”

レスポンス圧縮が既定で無効なのは、手前で既に圧縮していることが多く、ボディを 2 回 符号化しても誰の得にもならないからです。プロキシが圧縮していないのでなければ、 middleware.compression は無効のままにしてください。

ボディ上限は両側で効き、小さいほうが勝ちます。server.max_request_body の既定が 10 MiB、 nginx の client_max_body_size の既定が 1 MiB なので、アップロードがプロキシに拒否され、 ハンドラも走らずアクセスログにも残らない、ということが起こります。アプリケーションが リクエストを見ていない理由を考える前に、プロキシ側の上限を揃えてください。

ここで挙げたキーは設定リファレンスにすべて載っています。