コンテンツにスキップ

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

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

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

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

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

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 ブロックはどの行も何かを支えています。以降は、それぞれが何を支えて いるかの話です。

2 つあり、どちらも同じ 1 つの問いから出てきます。誰からの転送系ヘッダを証拠として 扱うか、です。リストに載った相手からのヘッダは信じます。同じヘッダでも他の相手から なら、偽とみなすのではなく、無かったものとして扱います。だから、名前を挙げていない プロキシの後ろにいるデプロイは、プロキシが無かった場合とまったく同じ答えを受け取り ます。HSTS は出ず、オリジンは http:// になり、訪問者全員がひとつのアドレスになります。

1 つ目は、クライアントが使ったスキームをプロセスが問うとき、X-Forwarded-Proto を 信じるかどうかです。直接の TLS 接続なら、それ自体が答えです。終端プロキシの後ろには それが無いので、リストが空ならすべてのリクエストが http になります。HSTS は HTTPS と 確認できたリクエストにしか送られないので、設定した Strict-Transport-Security と、 いつまでも出てこないヘッダのあいだに立っているのがこのリストです。HSTS を設定し、 プロキシの後ろにデプロイし、ヘッダが出ないのを眺める——その症状がこれで、直すのは このリストです。解決したそのスキームは、CSRF チェックがこのデプロイ自身のオリジンを 再構成するときにも、WebSocket のアップグレードがオリジンを照合するときにも、 plugin/auth が自分のオリジンと post-logout リダイレクト URL を組み立てるときにも 使われます。「これは HTTPS だったか」への答えは 1 つで、その全部をこのリストが門番して います。

2 つ目は、どのアドレスをクライアントとみなすかです。プロキシの後ろでは RemoteAddr はプロキシのもので、呼び出し元 1 つに蓋をするもの——レート制限の per_address バケット、匿名の訪問者に対する html.live_max_responses——は、そのままではデプロイ 全体を 1 人として数えます。接続相手がリストに載っていれば、フレームワークは X-Forwarded-For を右から辿り、それ自身が宣言済みのプロキシであるホップを飛ばして、 そうでない最初のアドレスをクライアントとします。信頼するプロキシが保証した最後の ホップです。鎖がすべて自分のプロキシで出来ていれば、いちばん左の値になります。 アドレスとして読めないホップに当たったら辿るのをやめ、TCP の接続相手を使います。 読めない鎖を途中まで信じることはできないからです。リストに載っていない相手からの ヘッダは無視され、その相手がクライアントです。ヘッダを名乗って新しい予算を手に入れる ことはできません。

ヘッダ 読んでいるもの
X-Forwarded-Proto クライアントが使ったスキーム。宣言したプロキシからだけ読む。HSTS を送るかどうかと、CSRF チェック、WebSocket のアップグレード、plugin/auth がこのデプロイ自身について再構成するオリジンのスキームを決める
X-Forwarded-For クライアントのアドレス。宣言したプロキシからだけ読む。per_address のレート制限バケットと html.live_max_responses はこれに対して数える
Host 再構成するオリジンのホスト側と、フレームワークが組み立てる絶対 URL のすべて
X-Forwarded-Host、Forwarded、X-Real-IP 開発時以外は何も読んでいない

2 つのヘッダはプロキシに逆のことを求めていて、上の nginx の設定はその両方をやって います。スキームは X-Forwarded-Proto の最初の値から読むので、プロキシはこのヘッダを 届いた値に追記するのではなく上書きしなければなりません。nginx の $scheme は 上書きします。クライアントの送った値を残す書き方をすると、スキームをクライアントが 名乗れます。X-Forwarded-For は右から読むので、プロキシは追記すべきです。 $proxy_add_x_forwarded_for がそれで、クライアントが偽造した値はプロキシが足した ホップの左側に残り、そこまで辿り着くことはありません。

値は IP アドレスか CIDR ブロックで、不正な値は無視されるのではなく起動に失敗します。 真であるもののうち最も狭いものを書いてください。同一ホストのプロキシなら 127.0.0.1、 サイドカーや Ingress ならその Pod ないしサブネットの CIDR です。狭くする理由は、挙げた ネットワークの内側にいるものは何でも両方のヘッダを名乗れ、その内側からポートに届く クライアントは辿る途中で信頼されるホップになるからです。プロキシの後ろでリストを空の ままにすると、どちらかのヘッダを運んできた最初のリクエストで、プロセスごとに 1 回だけ 警告がログに出ます。違うネットワークを挙げたリストに警告は出ません。ヘッダが黙って 無視されるだけです。

プロキシのアドレスが固定でないとき——プールから入れ替わる ALB、CDN、そもそも範囲を 教えてくれないプラットフォーム——リストが決めることにはそれぞれ、リストが無くても 済む手立てがあります。アプリケーションから HSTS を送るのをやめて、ロードバランサに送らせて ください。TLS 接続を終端しているのはその層で、アプリケーションが推測しようとしている 答えを最初から知っています。1 つの層がヘッダを設定するほうが、2 つの層が食い違うより ましです。公開オリジンは security.csrf.trusted_origins に書きます。次の節がそれ です。クライアントごとの蓋はロードバランサに任せてください。個々のクライアントがまだ 見えているのはそちらで、やり方は下のライブの蓋の節に あります。trusted_proxies = ["0.0.0.0/0"] に手を伸ばすのは、そのロードバランサ以外 からポートへ絶対に到達できない場合だけにしてください。書くなら、ネットワークだけが 境界だと宣言していることを承知の上で書くことになります。その内側では、誰でもスキームも クライアントアドレスも名乗れます。

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

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

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

CSRF のチェックは、ブラウザの Origin と、プロセスが自分自身について再構成したオリジンをスキーム込みで比較します。 そのスキームは前の節で解決したものです。r.TLS か、宣言したプロキシからの X-Forwarded-Proto か。リストが空ならどちらも無いので、プロセスは自分を http://app.example.com だと判断し、ブラウザは https://app.example.com だと 告げています。ホストは同じ、スキームは別、一致しません。

プロキシを宣言すればスキームは直り、ホスト名が 1 つのデプロイならそれで全部です。 2 つ目のリストは、プロキシがプロセスに教えられないことのためにあります。ブラウザが 正当に表示しうる他のオリジンと、宣言すべきアドレスを持たないプロキシの後ろにいる デプロイです。再構成したオリジンは比較の基準そのものなので、プロキシのリストが変え られるのはデプロイが自分を何と呼ぶかだけで、他にどのオリジンを通すかは変えられません。 それを決めるのがこちらのリストです。

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

どの直し方になるかは、見ているのが誰か、そしてプロキシに名前を付けられるかで決まり ます。ログインの内側にあるライブページはサブジェクトで識別されているので、何も変える 必要がありません。公開ページで、プロキシのアドレスが固定なら、trusted_proxies だけで 蓋は訪問者ごとに戻ります。公開ページで、プロキシに宣言すべきアドレスが無いときは、蓋を 移す必要があります。

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

0 は蓋を外します。安全なのは他の何かが接続数を制限している場合だけで、それができる層は プロキシです。個々のクライアントがまだ見えているのはそちらだからです。レート制限の per_address バケットにはそういうオフが無いので、名前を付けられないプロキシの後ろでは、 レート制限もエッジに任せてください。

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 なので、アップロードがプロキシに拒否され、 ハンドラも走らずアクセスログにも残らない、ということが起こります。アプリケーションが リクエストを見ていない理由を考える前に、プロキシ側の上限を揃えてください。

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