將 Cloud Run 流量置於 Cloudflare 背後的三層保護
一個公開的 run.app 網址可讓任何人繞過您的 CDN 並直接存取 Cloud Run。三個層級可以彌補這個漏洞:傳入流量限制、負載平衡器、機密標頭。
將 Cloudflare 放在 Cloud Run 服務前面,您就能獲得快取、WAF(Web 應用程式防火牆)以及 DDoS 緩解功能。但如果來源仍然可以透過其預設的 run.app URL 存取,那麼這一切都無濟於事,因為找到該 URL 的攻擊者可以直接繞過您設定的每一道防護。解決方法並非單一設定,而是三個獨立的層級,每個層級都能各自獨立地進行失效關閉 (fail-closed):在網路邊緣鎖定傳入流量、一個成為唯一公開入口的負載平衡器,以及一個用來證明請求確實來自 Cloudflare 的秘密標頭。這篇文章將逐一介紹這三個層級、各自的設定,以及何時您並不需要整個堆疊。
漏洞:run.app 預設為公開
當您部署 Cloud Run 服務時,Google 會提供一個類似 https://my-service-abc123-an.a.run.app 的 URL。預設情況下,該 URL 直接為網際網路提供服務。接著,您將一個網域指向 Cloudflare,Cloudflare 會代理您的服務,流量便會流經 CDN。這看起來很安全,但事實並非如此。
問題在於 run.app 的 URL 仍然有效。任何知道這個 URL 的人(而這些 URL 是可猜測的、會被記錄下來,並在參照位址標頭和錯誤頁面中洩漏)都可以直接向來源傳送請求。這條直接路徑會繞過您的快取,因此每個請求都是一次冷命中,會耗費您的運算成本。它會繞過您的 WAF,因此注入和機器人過濾永遠不會執行。它還會繞過 Cloudflare 的速率限制,因此單一指令碼就能夠猛烈攻擊服務,直到您的帳單或延遲不堪負荷。
隱藏 URL 並非一種防禦措施。只要該字串出現在任何一個日誌聚合器中,「隱晦式安全」就會失效。來源伺服器需要主動拒絕任何不是從前門進入的請求,而前門必須是唯一的入口。
第一層:將 Cloud Run 的傳入流量鎖定至負載平衡器
Cloud Run 有一個傳入流量設定,可控制哪些網路可以連線到服務。預設值為 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 錯誤。該服務不再位於公開網際網路上。它只能透過您即將建立的負載平衡器連線,而這正是您想要的扼制點。
這個設定完成了大部分的工作,但值得了解為什麼光靠它還不夠。傳入流量控制是一個網路事實:它根據請求的進入點進行篩選,而不是根據請求是否合法。一旦您在前面放置一個負載平衡器並將其公開,負載平衡器本身就是公開的。任何連線到負載平衡器的流量都會連線到您的服務。因此,第一層將入口縮小到單一的負載平衡器,而第二層和第三層則決定該負載平衡器可以轉送哪些內容。
第二層:在 Cloudflare 後方放置負載平衡器
在傳入流量被鎖定的情況下,唯一能連線到 Cloud Run 的是 Google Cloud 負載平衡器。您建立一個外部 HTTPS 負載平衡器,其後端是一個指向該服務的無伺服器網路端點群組 (Serverless Network Endpoint Group, NEG)。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 map、一個帶有憑證的 HTTPS proxy,以及一個帶有靜態 IP 的全域轉送規則。這個 IP 就是 Cloudflare 代理的目標。在 Cloudflare 儀表板中,您將您網域的 DNS 紀錄設定為該 IP,並開啟代理狀態(橘色雲朵)。現在的公開路徑是:使用者、Cloudflare、負載平衡器 IP、NEG,最後是 Cloud Run。
此時,您有兩扇門,而其中只有一扇應該是開著的。Cloudflare 是預期的那扇門。負載平衡器的 IP 是第二扇門,它仍然是公開的,因為全域轉送規則會回應整個網際網路。解析出您網域真實來源 IP 或掃描位址範圍的人,可以直接與負載平衡器通訊,並再次繞過 Cloudflare。第二層將暴露的介面從 run.app 移至一個原始 IP,這個 IP 雖然更難找到,但並未關閉。這就是第三層要關閉的。
第三層:只有 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>
來源伺服器接著會對每個請求檢查兩件事:標頭是否存在且相符,以及 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 載入秘密,而不是將其寫死在映像檔中,這樣可以讓它遠離您的原始碼和容器層,並讓您能夠輪替它。輪替是一個兩步驟的程序:在來源伺服器上將新值新增為第二個可接受的秘密,將 Transform Rule 切換到新值,然後在流量排空後移除舊值。在交換過程中,不會有任何請求被拒絕。
完整的請求路徑
一旦所有三個層級都部署完成,以下是完整的流程。每個躍點都有其任務,而每個拒絕點都是一個層級的安全關閉機制。
| 躍點 | 元件 | 任務 | 拒絕時機 |
|---|---|---|---|
| 1 | Cloudflare | 快取、WAF、速率限制、新增 X-Origin-Secret |
機器人或攻擊簽章符合 WAF |
| 2 | GCP 負載平衡器 | 終止 TLS、透過 URL map 路由 | (轉發;本身不是安全閘道) |
| 3 | Serverless NEG | 適應 Cloud Run | 不適用 |
| 4 | Cloud Run 輸入 | 捨棄非負載平衡器的流量 | 請求未透過負載平衡器或 VPC 抵達 |
| 5 | 來源中介軟體 | 驗證標頭與 Host | 標頭遺失、錯誤,或 Host 不符 |
一個合法的訪客會通過所有五個躍點。一個對原始 run.app URL 的請求會在第 4 個躍點被中止。一個跳過 Cloudflare 直接對負載平衡器 IP 發出的請求會通過第 4 個躍點,但在第 5 個躍點被中止,因為它沒有秘密標頭。沒有任何單一路徑可以在不經過 Cloudflare 的情況下到達您的處理程式。
為何是三層而非一層
一個顯而易見的問題是,當單靠秘密標頭似乎就能杜絕繞過時,為何還要費心使用全部三層。答案是,每一層都彌補了其他層的不同失效情境,而真正的事件來自於失效情境,而非理想路徑。
Ingress 控制是一項網路層級的保證,不依賴於您的程式碼是否正確。如果您發布了一個在某些路由上跳過標頭中介軟體的錯誤,Ingress 仍會讓原始的 run.app 網址無法被存取。秘密標頭是一項應用程式層級的保證,不依賴於 Google 的網路設定是否正確。如果有人在進行不相關的變更時,手誤將 Ingress 設定改回 all,標頭檢查仍會拒絕直接的連線。Host 驗證則捕捉了一個更狹窄的情境:一個有效的標頭被重放到錯誤的服務,或是被錯誤路由的請求。
這些是獨立的,因為它們會獨立地失效。單一控制意味著單一的錯誤——一次錯誤的點擊或一條遺漏的程式碼路徑——就會讓您的防護降至零。三個控制則意味著單一的錯誤只會讓您的防護從三層降至兩層,而在您注意到並修復它的期間,您仍然受到保護。縱深防禦的重點不在於任何單一層是薄弱的。而在於它們全部同時出錯的可能性,遠低於其中之一出錯的可能性。
成本,以及何時矯枉過正
這些都不是免費的。外部 HTTPS 負載平衡器有固定的每小時費用,在您處理任何請求之前,每月費用約為 18 美元,此外還有每條規則和資料的成本。對於一個業餘愛好性質的服務,這筆帳單可能會讓它所保護的運算成本相形見絀。這個設定也包含更多活動元件:一個 NEG、一個後端服務、一個 URL 映射、一個代理、一個憑證、一個轉送規則、一個轉換規則,以及為了維持運作的密鑰輪替。這意味著有更多可能出錯的設定環節,以及在出現問題時需要考慮的更多因素。
還有更輕量的選項,對於許多服務來說,它們是正確的選擇。Cloudflare 自家的 Origin Rules 加上驗證來源拉取(Cloudflare 與您的來源之間的相互 TLS),可以在完全不使用 GCP 負載平衡器的情況下,證明連線來自 Cloudflare,不過在 Cloud Run 上,您仍然需要一些東西來阻止 run.app URL 回應。單獨使用密鑰標頭,若沒有傳入鎖定,可以阻止隨意的繞過,但會讓來源保持公開可及,並完全依賴您的程式碼是否正確。這些方法中的每一種,都是用一層深度來換取較低的成本和較少的設定。
當威脅程度低且衝擊範圍小時,完整的堆疊就顯得矯枉過正。一個位於身份代理後方的內部工具、一個低流量的個人專案,或是一個直接攻擊只會造成幾分錢而非幾塊錢損失的服務,並不需要三層保護。當直接存取來源會造成實際損害時,才需要採用完整的三重保護:例如當快取繞過意味著金錢損失、當 WAF 正在執行有意義的工作,或當一個堅決的攻擊者找到您的來源 IP 是一個可能發生的事件而非理論上的情況時。根據突破前門會給您帶來的損失,來決定保護層的數量,並就此打住。