Cloud RunのトラフィックをCloudflare経由に限定する3つのレイヤー
公開の `run.app` URL を使用すると、誰でも CDN をバイパスして Cloud Run に直接アクセスできます。Ingress の制限、ロードバランサ、シークレット ヘッダーの 3 つのレイヤで、そのギャップを埋めます。
Cloud Run サービスの前に Cloudflare を配置すると、キャッシュ、WAF、DDoS 攻撃の吸収が得られます。しかし、オリジンがデフォルトの run.app URL で依然として到達可能な場合、それらの保護はどれも役に立ちません。なぜなら、その URL を見つけた攻撃者は、あなたが設定したすべての保護を素通りしてしまうからです。この解決策は、1 つの設定ではありません。それは 3 つの独立したレイヤーであり、それぞれが単独でフェイルクローズします。ネットワークエッジでのロックされたイングレス、唯一の公開された入口となるロードバランサー、そしてリクエストが実際に Cloudflare を経由したことを証明するシークレットヘッダーです。この記事では、これら 3 つすべて、それぞれの設定、そしてこのスタック全体が過剰となる場合について順を追って説明します。
ギャップ: run.appはデフォルトで公開されている
Cloud Runサービスをデプロイすると、Googleからhttps://my-service-abc123-an.a.run.appのようなURLが提供されます。デフォルトでは、そのURLはインターネットに直接サービスを提供します。次に、ドメインをCloudflareに向け、Cloudflareがサービスへのプロキシとなり、トラフィックがCDNを通過するようになります。これは安全に見えますが、そうではありません。
問題は、run.appのURLが機能し続けることです。このURLは推測可能であり、ログに記録され、リファラーヘッダーやエラーページで漏洩するため、それを知った人は誰でもオリジンに直接リクエストを送信できてしまいます。その直接パスはキャッシュをスキップするため、すべてのリクエストがコールドヒットとなり、コンピューティングコストが発生します。WAFをスキップするため、インジェクションやボットのフィルタリングは実行されません。そして、Cloudflareのレート制限もスキップするため、単一のスクリプトが、請求額やレイテンシが破綻するまでサービスを叩き続けることができてしまいます。
URLを隠すことは防御にはなりません。曖昧さによるセキュリティは、その文字列が1つのログアグリゲーターに表示された瞬間に破綻します。オリジンは、正規の入口(front door)を経由しなかったものをすべて積極的に拒否する必要があり、その正規の入口が唯一の侵入経路でなければなりません。
レイヤー1: Cloud Runのingressをロードバランサにロックする
Cloud Runには、どのネットワークがサービスに到達できるかを制御するingress設定があります。デフォルトはallで、これはパブリックなrun.appの動作です。必要な値はinternal-and-cloud-load-balancingです。これは、すべての直接トラフィックをドロップし、Google Cloudロードバランサ経由またはVPC内から到着するリクエストのみを受け入れます。
gcloud run services update my-service \
--region=asia-northeast3 \
--ingress=internal-and-cloud-load-balancing
この変更後、生のrun.app URLへのリクエストは、コンテナに到達する前にGoogleのエッジから404を返します。サービスはもはやパブリックインターネット上にはありません。これから構築するロードバランサを介してのみ到達可能であり、これこそがまさに求めているチョークポイントです。
この1つの設定がほとんどの作業を行いますが、それだけでは不十分な理由を理解する価値があります。Ingress制御はネットワークの事実です。リクエストが正当であるかどうかではなく、リクエストがどこから入ったかによってフィルタリングします。ロードバランサを前面に配置して公開すると、ロードバランサ自体がパブリックになります。ロードバランサに到達するものはすべて、サービスに到達します。したがって、レイヤー1はエントリを単一のバランサに絞り込み、レイヤー2と3がそのバランサが何を転送できるかを決定します。
レイヤ 2: Cloudflare の背後にロードバランサを配置する
Ingress がロックされているため、Cloud Run に到達できるのは Google Cloud ロードバランサのみです。サービスを指すサーバーレス ネットワーク エンドポイント グループ (NEG) をバックエンドとする、外部 HTTPS ロードバランサを構築します。NEG は、固定 IP やインスタンス グループを持たないサーバーレス プロダクトをロードバランサがターゲットにできるようにするアダプタです。
# 1. A serverless NEG that points at the Cloud Run service
gcloud compute network-endpoint-groups create my-service-neg \
--region=asia-northeast3 \
--network-endpoint-type=serverless \
--cloud-run-service=my-service
# 2. A backend service that uses the NEG
gcloud compute backend-services create my-service-backend \
--global \
--load-balancing-scheme=EXTERNAL_MANAGED
gcloud compute backend-services add-backend my-service-backend \
--global \
--network-endpoint-group=my-service-neg \
--network-endpoint-group-region=asia-northeast3
そこから、URL マップ、証明書付きの HTTPS プロキシ、静的 IP を持つグローバル転送ルールをアタッチします。その IP が、Cloudflare がプロキシする対象です。Cloudflare ダッシュボードで、プロキシ ステータスをオン (オレンジ色の雲) にして、ドメインの DNS レコードをその IP に設定します。これで、公開パスはユーザー、Cloudflare、ロードバランサの IP、NEG、Cloud Run の順になります。
この時点で、2 つのドアがあることになり、そのうちの 1 つだけが開いているべきです。Cloudflare が意図されたドアです。ロードバランサの IP は、依然として公開されている 2 番目のドアです。なぜなら、グローバル転送ルールはインターネット全体に応答するためです。ドメインの実際のオリジン IP を解決するか、アドレス範囲をスキャンする人は、ロードバランサと直接通信し、再び Cloudflare をバイパスできます。レイヤ 2 は、公開されたサーフェスを run.app から生の IP へと移動させました。これは見つけるのがより困難ですが、閉じられてはいません。それを閉じるのがレイヤ 3 です。
第3層: Cloudflareだけが追加できるシークレットヘッダー
最後の層は、リクエストがCloudflareを通過したことを証明するもので、これはネットワークレベルではなくアプリケーションレベルで行われます。Cloudflareは、プロキシするすべてのリクエストにシークレットヘッダーを挿入し、オリジンは正しい値を持たないリクエストをすべて拒否します。ロードバランサーのIPを直接攻撃する攻撃者は、シークレットを知らないため、このヘッダーを偽造できません。
このヘッダーは、Cloudflare Transform Rule([Rules]、次に[Transform Rules]、次に[Modify Request Header])で追加します。このルールは、オリジンに向かう前のすべての受信リクエストにカスタムヘッダーを設定します。
Rule: Add origin secret
When: (all incoming requests)
Then: Set static header
Header name: X-Origin-Secret
Value: <a long random string from Secret Manager>
次に、オリジンはすべてのリクエストで2つのことをチェックします。ヘッダーが存在し一致すること、そしてHostが期待するドメインであることです。Hostのチェックは、古いまたはコピーされたヘッダーを持ちながら、間違った仮想ホストをターゲットにしているリクエストを捕捉します。Goでは、ミドルウェアは小規模です。
func requireCloudflare(secret, wantHost string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if subtle.ConstantTimeCompare([]byte(r.Header.Get("X-Origin-Secret")), []byte(secret)) != 1 {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if r.Host != wantHost {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
チェックがタイミングによってシークレットを漏洩しないように、コンスタントタイム比較を使用します。シークレットはイメージに焼き込むのではなく、Secret Managerからロードします。これにより、ソースやコンテナレイヤーからシークレットを分離し、ローテーションさせることができます。ローテーションは2段階の手順で行います。オリジンで新しい値を2番目の受け入れ可能なシークレットとして追加し、Transform Ruleを新しい値に切り替え、トラフィックが流れきったら古い値を削除します。この切り替え中にリクエストが拒否されることはありません。
完全なリクエストパス
3つのレイヤーがすべて配置されると、全体のフローは次のようになります。各ホップにはジョブがあり、各拒否ポイントはレイヤーがフェイルクローズすることを示します。
| ホップ | コンポーネント | ジョブ | 拒否条件 |
|---|---|---|---|
| 1 | Cloudflare | キャッシュ、WAF、レート制限、X-Origin-Secretの追加 |
ボットまたは攻撃シグネチャがWAFに一致した場合 |
| 2 | GCPロードバランサー | TLSの終端、URLマップによるルーティング | (転送、それ自体はセキュリティゲートではない) |
| 3 | サーバーレスNEG | Cloud Runへの適合 | なし |
| 4 | Cloud Runイングレス | ロードバランサー以外からのトラフィックをドロップ | リクエストがバランサーまたはVPC経由で到着しなかった場合 |
| 5 | オリジンミドルウェア | ヘッダーとHostの検証 | ヘッダーがない、間違っている、またはHostが一致しない場合 |
正当なビジターは5つすべてを通過します。生のrun.app URLへのリクエストはホップ4で停止します。CloudflareをスキップするロードバランサーIPへのリクエストはホップ4を通過しますが、シークレットヘッダーがないためホップ5で停止します。Cloudflareを経由せずにハンドラに到達する単一のパスは存在しません。
なぜ1つではなく3つのレイヤーなのか
シークレットヘッダーだけでバイパスを防げるように見えるのに、なぜ3つすべてをわざわざ使うのか、というのは当然の疑問です。その答えは、各レイヤーが他のレイヤーの異なる障害をカバーし、実際のインシデントはハッピーパスからではなく、障害から発生するからです。
Ingress制御は、コードが正しいことに依存しないネットワークの保証です。あるルートでヘッダーミドルウェアをスキップするバグを出荷してしまっても、Ingressは生のrun.app URLを非公開に保ちます。シークレットヘッダーは、Googleのネットワーク設定が正しいことに依存しないアプリケーションの保証です。もし誰かが無関係な変更の際に、誤ってIngressの設定をallに戻してしまっても、ヘッダーチェックは直接のアクセスを拒否します。Host検証は、間違ったサービスに対してリプレイされた有効なヘッダーや、誤ってルーティングされたリクエストといった、より狭いケースを捕捉します。
これらは独立して失敗するため、互いに独立しています。1つの制御だけでは、1つのミス、つまり1回の誤ったクリックや見逃されたコードパスが、防御をゼロにしてしまうことを意味します。3つの制御があれば、1つのミスがあっても3つのレイヤーから2つに減るだけで、それに気づいて修正する間も保護され続けます。多層防御の要点は、いずれか1つのレイヤーが弱いということではありません。それらすべてが同時に間違っている可能性が、そのうちの1つが間違っている可能性よりもはるかに低いということです。
コスト、そしてこれが過剰となる場合
これらはどれも無料ではありません。外部HTTPSロードバランサーには、1つのリクエストを処理する前に月額18ドル程度の固定時間料金がかかり、さらにルールごとおよびデータコストが加算されます。趣味のサービスの場合、その請求額は保護対象のコンピューティング費用を上回る可能性があります。このセットアップは、NEG、バックエンドサービス、URLマップ、プロキシ、証明書、転送ルール、変換ルール、そして運用を続けるためのシークレットのローテーションといった、より多くの可動部分で構成されています。これは設定ミスを起こしやすい箇所が増えるということであり、何かが壊れたときに原因を究明すべき箇所が増えるということです。
より軽量なオプションもあり、多くのサービスにとってはそれが正しい選択です。Cloudflare独自のOrigin Rulesと認証済みオリジン取得 (Cloudflareとオリジン間の相互TLS) を組み合わせることで、GCPロードバランサーを全く使わずにCloudflareからの接続であることを証明できますが、Cloud Run上では run.app URLが応答しないようにするための何らかの仕組みが依然として必要です。シークレットヘッダー単体では、イングレスをロックしない場合、安易なバイパスは防げますが、オリジンは公開されたままとなり、コードが正しいことだけに完全に依存することになります。これらのそれぞれは、より低いコストとより少ないセットアップと引き換えに、防御の深さを一層犠牲にしています。
脅威が低く、爆風半径 (影響範囲) が小さい場合、この完全なスタックは過剰です。IDプロキシの背後にある内部ツール、トラフィックの少ないサイドプロジェクト、または直接的な攻撃による損害が数ドルではなく数セントで済むサービスには、3つの層は必要ありません。オリジンへの直接アクセスが実際に損害をもたらす場合に、完全な三重の保護に手を伸ばしてください。つまり、キャッシュのバイパスが実質的な金銭的損失を意味する場合、WAFが意味のある仕事をしている場合、または断固とした攻撃者がオリジンIPを見つけることが理論上のものではなく、もっともらしい出来事である場合です。フロントドアの突破がもたらすコストに見合った数の層を構築し、そこで止めましょう。