週二下午兩點,陽光正好。
經過昨天對登入流程的魔鬼重構,BFF 正式接管了前端與後端微服務之間的所有 API 代理轉發。
前端小杰翹著二郎腿,看著終端機裡流暢滑過的 200 OK 請求日誌,愜意地吹了個口哨:
「搞定!登入固若金湯,Token 鎖在後端,前端只帶 SameSite=Strict Cookie。現在 BFF 每天幹的事情很純粹——收到前端請求、戴上 Token、轉發給後端微服務。這總不可能再出事了吧?」
坐在後面的架構師老陳冷不防端著保溫杯幽幽地說了一句:
「小杰,當一個人覺得絕對不可能出事的時候,通常就是閻王爺在點名的時候。」
話音剛落,辦公室的大門再次被撞開——這次衝進來的是電商運營主管,臉上毫無血色:
「研發組!我們被黑客薅羊毛薅禿了!
剛才半個小時內,系統湧入了幾百筆異常訂單,全都是以『正牌登入用戶』的身分下單購買昂貴的 iPhone,收件地址卻全被填成了同一個廢棄倉庫!
更詭異的是,那些受害者都在線上客服破口大罵,說他們這半小時根本沒打開過我們的 App,只是在看我們公司的官方宣傳部落格!」
小杰手裡的鍵盤啪嗒一聲掉在桌上:「什麼?!看官方部落格……也能在我們這裡被下單?!」
老張推開筆電,深吸了一口氣:
「行銷部的那個部落格……是不是架在 blog.example.com?」
「老張,這說不通啊!」小杰急得跳腳:「我們昨天才親手把 Session Cookie 設為最高等級的 SameSite=Strict!照規範,跨站發送的請求,瀏覽器絕對不會帶上 Cookie,這怎麼可能是 CSRF(跨站請求偽造)?!」
「小杰,你忽略了最致命的四個字:『同站(Same-Site)』不等於『同源(Same-Origin)』!」老張在白板上重重畫了兩個圓圈:

全場工程師瞬間如遭雷擊。
行銷團隊上個月為了發布新品,用 WordPress 隨手在子網域 blog.example.com 架了個部落格,還裝了一堆未經安全審查的第三方外掛。黑客攻破了部落格,在裡面埋了一段腳本。
當我們忠實的使用者一邊在瀏覽器登入著主站,一邊開開心心看部落格新文章時:
部落格的腳本在背景悄悄向 bff.example.com/api/orders 發送購買 iPhone 的請求!
因為兩者都屬於 example.com,瀏覽器判定為「同站」,毫不猶豫地自動帶上了受害者的 Session Cookie!

「怎麼解?」我問。
老張在代碼 day3/bff/csrf.go 裡拍板了防禦鐵律:
「所有能修改狀態的 API,強制要求出示自訂 HTTP Header:X-BFF-CSRF: 1!」
// 詳見 day3/bff/csrf.go
const csrfHeader = "X-BFF-CSRF"
func (b *BFF) csrfGuard(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// 1. 凡是進來的 API,必須帶上這個自訂 Header!
if r.Header.Get(csrfHeader) != "1" {
http.Error(w, "csrf: missing header", http.StatusForbidden)
return
}
// 2. 縱深防禦:若瀏覽器帶有 Origin,必須與主站嚴格相符!
if o := r.Header.Get("Origin"); o != "" && o != b.cfg.AppOrigin {
http.Error(w, "csrf: bad origin", http.StatusForbidden)
return
}
next(w, r)
}
}
這個防禦之所以能 100% 封殺 CSRF,原理極其巧妙:
<form>、<img>、<script> 標籤,根本沒辦法自己加 Header!fetch 加 Header,瀏覽器底層會被強制觸發 CORS 預檢請求(OPTIONS Preflight)。Access-Control-Allow-Headers),預檢直接被拒,黑客的跨站請求連微服務的大門都碰不到!剛把 CSRF 漏洞補上,運維監控螢幕上又亮起了紅燈。
內部微服務的監控日誌中,赫然出現了一筆本不該被外網觸碰的內部端點呼叫:GET http://internal-admin-service/api/admin/dump_database!
「這又是哪來的?!」小杰抱頭哀嚎:「我們的 BFF 明明寫了過濾器啊!只允許前端轉發 /api/public/ 開頭的路徑,而且明確寫了 !strings.Contains(path, ".."),黑客是怎麼跳到 /admin 的?!」
老陳嘆了口氣,打開了我們專門用 Rust 寫的滲透實驗庫 day3/egress-policy/:
「小杰,這叫 URL 解碼的巴別塔效應。檢查路徑的人(BFF),和真正執行路徑的人(微服務),對 URL 的看法根本不是同一個世界!」

在 Rust 測試 naive_filter_is_fooled_and_the_upstream_really_escapes 中,我們親手驗證了黑客的各種奇巧淫技:
%2e%2e
%252e%252e
..;/admin
「不要再去寫什麼正則表達式過濾器了!」我果斷說道:「跟黑客玩編碼捉迷藏永遠只會被玩死。」
最優雅的防禦,就是讓前端根本沒有機會指定路徑!
在 day3/bff/api.go 中,我們全面改用靜態路由映射表(API Facade):
// 詳見 day3/bff/api.go
func (b *BFF) registerAPI(mux *http.ServeMux) {
mux.HandleFunc("GET /api/me/orders", b.apiGuard(b.myOrders))
mux.HandleFunc("GET /api/users/{id}/orders", b.apiGuard(b.userOrders))
mux.HandleFunc("POST /api/orders", b.apiGuard(b.createOrder))
}
前端只能呼叫這幾支焊死在代碼裡的端點,黑客就算把網址打出花來,也絕不可能把請求導向 /admin!
好不容易處理完轉發問題,實習生阿呆忽然怯生生地舉手:
「前輩……我剛剛在測試手機版首頁時,打開瀏覽器 F12 把網址裡的 ID 改了一下……」
「你改了什麼?」我心裡咯噔一下。
「我把 GET /api/users/user-123/orders 改成了 GET /api/users/user-999/orders,結果……我看到了公司執行長的私人購車訂單和住家地址……」
全場空氣再次凝固。
這就是著名的 BOLA(Broken Object Level Authorization,失效的物件層級授權,俗稱 IDOR)——連續多年蟬聯 OWASP API 安全排行榜榜首的頭號殺手!

user-123(Cookie 與 Session 通過)。user-123 到底能不能看執行長的訂單!在 day3/bff/api.go 中,我們推行了最純粹的**「零信任無參數路徑」**:
// ⭐️ 強烈推薦:網址上壓根不出現 ID,由 BFF 從安全 Session 中自動代入!
func (b *BFF) myOrders(w http.ResponseWriter, r *http.Request, s *session) {
b.callUpstream(w, s, http.MethodGet, "/users/"+url.PathEscape(s.sub)+"/orders", nil)
}
前端呼叫 /api/me/orders,根本不給任何人竄改 ID 參數的機會!沒有參數,就沒有漏洞!
時間來到中午十二點整。電商雙十一預熱活動正式開跑。
幾萬名活躍使用者同時打開手機 App 首頁。
突然,伺服器監控出現了駭人的一幕:全站所有使用者的 Session 在同一秒內呈雪崩式集體註銷,所有人被莫名其妙踢出登入!
「怎麼回事?!黑客發動 DDoS 攻擊了嗎?!」小杰尖叫。
「不是黑客,是我們自己的程式在向自己開槍!」老陳指著授權伺服器(IdP)的警報日誌:
「觸發重放防盜(Reuse Detection)自爆機制!」

現代 IdP 普遍啟用了 Refresh Token Rotation(旋轉防盜):舊 Token 只要用過一次就必須自毀。如果同一個舊 Token 被送進來第二次,IdP 就會判定「金鑰被黑客盜取了」,為了保護使用者,直接自爆註銷該帳號!
首頁並發發送了 5 個 API,結果請求 2 撞上了剛被請求 1 作廢的舊 Token,自己人打架,把自己打成了黑客!
在 day3/bff/refresh.go 中,我們為每個 Session 加上了一把刷新排隊鎖:
// 詳見 day3/bff/refresh.go
func (b *BFF) accessTokenFor(s *session) (string, error) {
s.tokMu.Lock()
defer s.tokMu.Unlock()
// 檢查 Token 是否真的過期 (預留 AccessSkew 緩衝)
if b.now().Add(b.cfg.AccessSkew).Before(s.accessExp) {
return s.accessToken, nil // 後面排隊進來的請求,醒來時看到的已經是換好的新 Token!
}
// 只有排在第一名的請求會真正去向 IdP 換 Token
resp, err := b.hc.PostForm(...)
...
}

在單元測試 TestRefresh_ConcurrentRequestsRefreshExactlyOnce 中,我們同時轟入 20 個並發請求,驗證 IdP 精準只被呼叫了 1 次,徹底解決了中午發生的全站掉登入自毀慘劇!
時鐘指向夜間十一點。
經過一整天的極限救火,終端機裡的所有測試全部綠燈通過:
cd day3/bff && go test -race -v -count=1 ./...
cd ../egress-policy && cargo test
# 20 項 Go 測試 + 7 項 Rust 滲透測試全部 PASS!
會議室裡,小杰癱倒在椅子上,心有餘悸:
「CSRF 繞過、路徑穿越、BOLA 越權、還有 Token 併發旋轉自爆……今天發生的任何一個漏洞,隨便一個都能讓我們公司直接上社會新聞頭條。」
老陳微笑著合上筆記型電腦:
「這就是當代理人的代價。今天我們解決了『單一微服務請求的安全性』。
但明天,真正的架構擴張就要來了:
User 型別,你的 BFF 是不是又要爆炸?
小杰坐直了身子,眼中雖然疲憊,卻燃起了前所未有的工程鬥志:
「來吧!明天不管是虛擬圖譜還是混淆代理人,我們一個都不會放過!」
(未完待續,請見 Day 4:【第三夜:暴走的大亂鬥】一張圖,多個 BFF——Adapter、Lens、Namespacing 與 Composition)