バックエンドサービスへのプロキシ
API サーバーはもう存在しています。別の言語で書かれていたり、別の誰かが保守していたり して、そこにブラウザから——このアプリケーションが描画するページと同じ画面から—— アクセスしたい。別ホスト名で配信しても動きますが、安全でないリクエストのたびに CORS の preflight が付き、「今ログインしているのは誰か」という問いに答える仕組みをもう 1 つ持つ ことになります。この道は通れます——クロスオリジンリクエストで 設定します——が、残るのはその 2 つ目の仕組みを保守し続けることです。両方を nginx の後ろに並べればオリジンは 1 つになりますが、ログインは 解決しません。プロキシはリクエストをそのまま API に渡し、API は自前のセッションの 考えを持つ羽目になります。
プロキシをアプリケーションの内側に載せれば、リクエストはまずここに届きます。ミドル ウェアのスタック——セッションの解決、認証、CSRF チェック、ボディ上限、リクエスト タイムアウト——を全部通り、それから上流へ出ていきます。後ろにいるサービスは認証を 一切持たないままで構いません。あなたの認証を通っていないものは、そこに何も届かない からです。
net/http/httputil は TinyGo でビルドできません。そこでプロキシは
tinygodriver/httprevproxy から取ります。公開 API は標準パッケージのリバースプロキシ
部分をそのまま写したものです。Popcorn Web のプロジェクトは tinygodriver を既に
require しています——pw.ServeMux がそこの型です——ので、追加する依存はありません。
httputil という別名で import しておけば、呼び出し側は標準パッケージを使うときとまったく同じに読めます。
package handlers
import ( "net/url"
httputil "github.com/shibukawa/tinygodriver/httprevproxy")
// このアプリケーションが前に立つ API サーバー。ループバックで待ち受けていて、// 認証は自前では持っていない。var apiTarget = &url.URL{Scheme: "http", Host: "127.0.0.1:9000"}
func init() { proxy := &httputil.ReverseProxy{ Rewrite: func(r *httputil.ProxyRequest) { r.SetURL(apiTarget) r.SetXForwarded() }, } mux.Handle("/api/", proxy)}このファイルは他のハンドラと並べて handlers パッケージに置きます。mux はそれらが
登録しているのと同じ pw.NewServeMux() です。これで GET /api/users は API サーバーに
届き、プレフィクス配下の他のものも同様に届きます。末尾のスラッシュが部分木パターンに
なるのは、pw.ServeMux でも net/http.ServeMux と同じです。
そのリクエストが既に通ってきたもの
Section titled “そのリクエストが既に通ってきたもの”mux に載せたハンドラはミドルウェアスタックの 最も内側です。プロキシに届いたリクエストは、その上のフレームをすべて通過しています。 手前ではなくここでプロキシする理由が、まさにこれです。
| 上流にとっての意味 | |
|---|---|
| セッションと認証 | 未認証の呼び出し元はサービスまで届かない |
| パス保護とガード | ルート単位の認可がプロキシ先のパスにも効く |
server.max_request_body |
大きすぎるアップロードは 1 バイトも転送される前に拒否される |
middleware.request_timeout |
自分のハンドラだけでなく上流への往復も含めて縛る |
| リクエスト ID とアクセスログ | プロキシしたリクエストにも 1 行、他と相関の取れた形で残る |
したがって上流のサービスは、他のどこからも到達できない場所——ループバックか私設
ネットワーク——に置いてください。公開アドレスを持った瞬間、この配置が与えている保護は
curl 1 回で迂回できるものになります。
Director ではなく Rewrite を使う
Section titled “Director ではなく Rewrite を使う”フィールドは両方ありますが、設定してよいのはちょうど片方だけで、両方あるいはどちらも
設定しないと、起動時のエラーではなく毎リクエストの 502 になります。Rewrite を
設定してください。
Director は標準パッケージとの互換のために残っているもので、向こうでは deprecated
です。そして違いは書き味の問題ではありません。Rewrite の経路は、あなたの関数が
走る前に送信側リクエストから Forwarded と X-Forwarded-* をすべて削除します。
偽装した X-Forwarded-For をクライアントが上流まで潜り込ませることはできず、その後
SetXForwarded() が本物の値を書きます。Director は——そしてそれを使う
NewSingleHostReverseProxy は——クライアントが送った値を残し、そこに追記します。
上流がそのヘッダを何かに信用しているなら、この違いが脆弱性そのものです。
上流に見えるパス
Section titled “上流に見えるパス”SetURL はターゲットのベースパスに受信パスを連結します。パスを持たないターゲットなら、
上流にはブラウザが送ったとおりのリクエストが見えます。/api/users は /api/users の
ままです。サービスが同じプレフィクスの下に自分のルートを定義しているなら、これが
正解です。
サービスがここでは持っていない root を前提にしている場合は、通過する途中でマウント
位置を剥がします。import に net/http を足してください。
mux.Handle("/api/", http.StripPrefix("/api", proxy))/api/users は上流で /users になります。どちらにするかは、サービスが実際に宣言して
いるルートを見て一度決めてください。食い違うと、上流の 404 として——つまり自分の
アプリケーションのルーティングのバグに見える形で——出てきます。
身元を上流へ渡す
Section titled “身元を上流へ渡す”サービスは認証しませんが、誰が尋ねているかはたいてい知る必要があります。Rewrite は
受信リクエストを手にした状態で走り、その時点でフレームワークはセッションを解決済み
なので、答えはもうコンテキストの中にあります。上の Rewrite フィールドを次で置き換え、
import に github.com/shibukawa/popcornweb/pw を足します。
Rewrite: func(r *httputil.ProxyRequest) { r.SetURL(apiTarget) r.SetXForwarded() if auth := pw.RequestAuthentication(r.In.Context()); auth.Authenticated { r.Out.Header.Set("X-User", auth.Subject) }},その名前で届いたものを転送するのではなく、こちらから設定する——Set がやっているのは
それで、値が信用できるのもそれゆえです。Rewrite は受信リクエストのクローンから
始まるので、クライアントの送った X-User は何かが上書きするまで Out に載っています。
上流が信用するヘッダは、未認証のリクエストも含めて毎回書かなければなりません。ヘッダが
無いことを匿名と解釈する上流が相手なら、else で Del を呼ぶ形が安全です。
運べないもの
Section titled “運べないもの”プロトコルアップグレードは非対応で、設定で変わりません。Upgrade を持つリクエストも、
101 を返す上流も、ErrorHandler に渡り、既定のハンドラでは 502 になります。
WebSocket はこのハンドラを通りません。1xx レスポンスも同様なので、上流の Early Hints
がブラウザに届くことはありません。
サービスが WebSocket を必要とするなら、この層は適していません。アップグレードを扱える プロキシを両方の手前に置き、TLS を終端するプロキシに必要な設定を 与えてください。HTTP のままのパスについては、このページの残りがそのまま当てはまります。
ストリーミングのほうは通ります。text/event-stream のレスポンス、および長さの分からない
レスポンスは、溜め込まずに届いた分から flush されます。FlushInterval を設定しなくても、
上流の server-sent events はそのままブラウザに届きます。
フレームワーク側で効き続けるもの
Section titled “フレームワーク側で効き続けるもの”プロキシを通す以上、フレームワーク自身の設定のうち 2 つは判断が要ります。
CSRF はマウント位置も対象です。 security.csrf.include の既定は /** なので、
ブラウザから /api/ への POST は他と同じくトークンを要する安全でないリクエストです。
pw_csrf クッキーを読んで X-CSRF-Token で送ってください。
htmx のガイドにあるヘルパーが
そのまま使えます。もう一方の選択肢は security.csrf.exclude ですが、これが誠実なのは
上流が自分で呼び出し元を認証している場合だけです。こちらを信用しているサービスの手前で
パスを除外するのは、チェックを移すことではなく外すことです。
圧縮は効きません。 middleware.compression が符号化するのはフレームワーク自身の
HTML ライタが書いたものだけで、プロキシしたレスポンスはそこに含まれません。上流が付けた
Content-Encoding はそのまま通り抜けます。これが正しい結果です。サービスの側で既に
決まっているからです。
ここで挙げたキーは設定リファレンスにすべて載っています。
