コンテンツにスキップ

バックエンドサービスへのプロキシ

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 回で迂回できるものになります。

フィールドは両方ありますが、設定してよいのはちょうど片方だけで、両方あるいはどちらも 設定しないと、起動時のエラーではなく毎リクエストの 502 になります。Rewrite を 設定してください。

Director は標準パッケージとの互換のために残っているもので、向こうでは deprecated です。そして違いは書き味の問題ではありません。Rewrite の経路は、あなたの関数が 走る前に送信側リクエストから ForwardedX-Forwarded-* をすべて削除します。 偽装した X-Forwarded-For をクライアントが上流まで潜り込ませることはできず、その後 SetXForwarded() が本物の値を書きます。Director は——そしてそれを使う NewSingleHostReverseProxy は——クライアントが送った値を残し、そこに追記します。 上流がそのヘッダを何かに信用しているなら、この違いが脆弱性そのものです。

SetURL はターゲットのベースパスに受信パスを連結します。パスを持たないターゲットなら、 上流にはブラウザが送ったとおりのリクエストが見えます。/api/users/api/users の ままです。サービスが同じプレフィクスの下に自分のルートを定義しているなら、これが 正解です。

サービスがここでは持っていない root を前提にしている場合は、通過する途中でマウント 位置を剥がします。import に net/http を足してください。

mux.Handle("/api/", http.StripPrefix("/api", proxy))

/api/users は上流で /users になります。どちらにするかは、サービスが実際に宣言して いるルートを見て一度決めてください。食い違うと、上流の 404 として——つまり自分の アプリケーションのルーティングのバグに見える形で——出てきます。

サービスは認証しませんが、誰が尋ねているかはたいてい知る必要があります。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 に載っています。 上流が信用するヘッダは、未認証のリクエストも含めて毎回書かなければなりません。ヘッダが 無いことを匿名と解釈する上流が相手なら、elseDel を呼ぶ形が安全です。

プロトコルアップグレードは非対応で、設定で変わりません。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 はそのまま通り抜けます。これが正しい結果です。サービスの側で既に 決まっているからです。

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