因為近期一些資安事件的影響,發現常見的 BFF 系統架構其實沒做好也是個漏洞。
因此這一系列將簡單來分享前後端分離架構中常見的 BFF 架構以及今年8月正式從草案轉正的 RFC 10017OAuth 2.0 for Browser-Based Applications,還有簡單介紹 RFC 9449 DPoP。
且系列中也會稍微提到 Token 相關事情,畢竟現在開發者都知道發放跟驗證 JWT token,但它也在某些場景下也是個漏洞。
整個系列會有七篇,這是第一篇,主要介紹一些知識。
那是個尋常的週五傍晚五點半。我剛收拾好背包,正盤算著週末去吃哪家麻辣鍋,產品總監小杰突然氣急敗壞地衝進辦公室,把手機重重摔在會議桌上:
「你看看!這是我們的電商 App 在捷運地下室打開的樣子!足足轉圈轉了四秒鐘!App Store 上全是『首頁卡成狗、立刻退坑』的一星差評,到底在慢什麼?!」
我苦笑著接過手機,連上代理抓包工具一看,差點沒噴出一口老血——
為了一個僅僅需要顯示「使用者暱稱」、「最近一筆訂單金額」和「推薦商品」的簡單首頁,我們的手機前端在孱弱的 4G 網路下,像個無頭蒼蠅一樣打了整整三趟不同的微服務 API!
更令人窒息的還在後頭。五分鐘後,資安負責人老張推門進來,臉色慘白得像剛看完鬼片:
「小杰你先別管效能了……我們出大事了。剛才第三方資安通報:前端團隊上週引入的一個 npm 輪播圖外掛被黑客供應鏈投毒,包含了一段 XSS 腳本。攻擊者剛剛把全站使用者的
localStorage洗劫一空,我們發給使用者的 Token……全部被黑客外帶回家了。」
會議室裡頓時陷入死一般的寂靜。
這是一個典型的現代軟體災難現場:
這兩份文獻明明來自兩個完全不同的平行宇宙,卻在今天傍晚狠狠撞在了一起。
就在全體準備通宵救火的絕望時刻,沉默許久的架構師老陳默默走上前,在白板中央重重畫了一個方塊,寫下三個大字:BFF(Backend for Frontend)。

「老陳,我們的後端不是早就拆成先進的微服務了嗎?為什麼 App 還會慢成這樣?」小杰不解地問。
我嘆了口氣,在白板上畫出我們現在的架構:

「看懂了嗎?」我對小杰解釋:「在微服務的世界裡,每個服務只管自己的一畝三分地:
User Service 是通用服務,回傳了整整 50KB 的大包 JSON(包含使用者地址、歷史標籤、甚至身分證號),但手機首頁其實只要使用者的暱稱!Order Service 的工程師把資料庫改個欄位名,全公司所有手機 App 立刻集體閃退!」「早在 2015 年,串流音樂巨頭 SoundCloud 就遭遇過一模一樣的崩潰危機。」老陳接過話頭:「當時的工程主管 Phil Calçado 提出了這個模式:為特定端點打造專屬後端(Backend for Frontend,簡稱 BFF)。」

BFF 就像是派駐在後端機房裡的「外帶打包員」:
「那太好了!」小杰興奮地說:「我們立刻開搞!iPhone 15 開一個 BFF、Android 開一個 BFF、iPad 再開一個!」
「停!千萬別!」老陳猛按暫停鍵,指著 bff-patterns.com 的警告:
「切記:不是『一台裝置一支 BFF』,而是『一種客戶端體驗(Experience Channel)』一支 BFF!」

iOS 和 Android 的畫面 95% 是一樣的。如果你照硬體切,你只會養出兩份幾乎一樣、每天都要同步改 Bug 的代碼地獄!
當天晚上,我立刻用 Go 語言寫了一個最小的 BFF 聚合器原型(位於專案 https://github.com/tedmax100/bff-oauth-security/tree/main/day1/aggregator)。面對兩個上游服務,BFF 使用 Goroutine 平行併發發射:
// 詳見 day1/aggregator/main.go
func (b *BFF) fetch() (User, []Order, error) {
var (
wg sync.WaitGroup
u User
orders []Order
uErr, oErr error
)
wg.Add(2)
// 在內網用並發打上游,不再讓前端等瀑布流!
go func() { defer wg.Done(); u, uErr = getJSON[User](b.client, b.upstream+"/users/1") }()
go func() { defer wg.Done(); orders, oErr = getJSON[[]Order](b.client, b.upstream+"/users/1/orders") }()
wg.Wait()
if uErr != nil {
return u, nil, uErr
}
return u, orders, oErr
}
跑跑測試:
cd day1/aggregator && go test ./...
# 驗證輸出:
# /mobile/home 僅輸出 {"name":"Nathan","latest_order_total":900} (成功抹除敏感 email)
# /web/home 輸出包含完整訂單歷史的大包 JSON
小杰看著不到 50ms 就回傳的乾淨資料,雙眼放光:「太神了!首頁效能問題搞定了!那我們是不是可以去吃火鍋了?」
「吃火鍋?」站在一旁的老張冷笑了一聲:「你們前端的效能是快了,但全公司的金庫大門剛剛已經被人卸下來搬走了!」
老張轉身打開投影機,螢幕上赫然是暗網論壇的截圖,上面正在兜售我們系統的 API Access Token 與 Refresh Token。
「這就是我們過去採用的純前端 SPA 裸奔架構的代價。」老張指著屏幕:

老張敲了敲白板:
「這就是 IETF 剛出爐的 RFC 10017 (BCP 212) 最嚴厲的警告:
『在同一個 Origin(同源)內,不存在任何能阻擋惡意 JavaScript 的前端儲存空間!』
只要發生 XSS,攻擊者就能把 Token 『可帶走(Exfiltration)』!他把 Token 存進自己的 Postman,受害者就算把電腦砸了,黑客依然能日日夜夜用他的身分下單、轉帳、讀私密資料!」
「那如果把 Token 藏在 SessionStorage 呢?或者藏在記憶體變數裡?」小杰不甘心地問。
「RFC 10017 第 8 節早就把所有的瀏覽器儲存手段全都槍斃了一遍!」老張冷冷地說:
localStorage / sessionStorage / IndexedDB:同一網域下的惡意腳本一行代碼就能讀光。window.fetch,在 Token 發送的瞬間就能當場攔截!navigator.serviceWorker.getRegistrations() 把你的 Worker 註銷掉,然後在背景重新走一次認證流程!「那到底該把 Token 藏去哪裡?」我問。
老張指了指老陳剛才畫的 BFF 方塊:
「把 Token 徹底趕出瀏覽器!讓 BFF 來當 OAuth 的 Confidential Client!」

老張用了一個無比精妙的比喻,點醒了在場的所有人:
HttpOnly Cookie)」。強盜搶走包包,根本找不到鑰匙!
BFF 的資安真諦:
它不可能 100% 消滅 XSS(那需要靠 CSP 和嚴格輸入過濾),但它做了一件價值連城的事:
把「可離線帶走、無限期洗劫的 Token 外洩」,成功降級為「只能趁使用者在線時當面脅迫的代理請求」!
看著大家若有所思的樣子,老張在白板上寫下了 RFC 10017 總結的黑客四大招數:

小杰忍不住舉手:「老張,我們前端界最近很紅的 DPoP(RFC 9449) 呢?它把 Token 綁定在瀏覽器的非對稱私鑰上,黑客就算偷走 Token 也沒私鑰可用啊!」
老張冷哼了一聲:「RFC 10017 第 5.1.3 節就是專門用來打臉這種天真想法的:
<iframe>,網址指向授權伺服器的 /authorize。code。code,用黑客自己產生的全新 DPoP 私鑰去換取一把全新的 Token!「那 BFF 憑什麼能防住這招?」我問。
「因為在 BFF 架構下,OAuth 客戶端是機密客戶端(Confidential Client)!」老張敲著桌子:「要拿 code 去換 Token,除了出示驗證暗號,還必須出示只有 BFF 伺服器內網才知道的 client_secret!瀏覽器裡的惡意腳本沒有這把密鑰,換 Token 的請求在授權伺服器門口會被直接擊斃!」
為了徹底驗證老張的推論,我們當場打開了專案裡的實驗代碼 day1/xsslab:
cd day1/xsslab && go run .
# 伺服器啟動在 http://127.0.0.1:8081
我們打開瀏覽器,分別造訪:
/spa:模擬傳統 SPA 模式(Token 存放在 localStorage)/bff:模擬 BFF 模式(Token 鎖在伺服器,前端僅有 HttpOnly Cookie)依序點下 Login,然後點下象徵惡意腳本被觸發的 Run injected script 按鈕:

綠色的終端機螢幕上清晰地跳出兩組對照結果。
黑客確實沒辦法從 /bff 偷走任何 Token 字串,但最後一行的 request as victim => 200 {"user":"nathan"} 依然刺痛了小杰的眼睛:
「也就是說……就算我們用了 BFF,黑客趁使用者還在線的時候,依然能發請求?」
「沒錯,這就是真實世界的資安。」老陳點點頭:「資安從來不是非黑即白的童話。BFF 把原本『全公司使用者 Token 被拖庫、暗網兜售數月』的核彈級災難,成功壓制在『使用者關閉分頁即止血』的局部傷口。這就是架構設計的取捨與價值!」
時鐘指向深夜十一點半。小杰長舒了一口氣:
「懂了!我們下週就把架構切成 BFF!把 Token 全收回伺服器,發 HttpOnly Cookie 給前端!」
「等等,」老張合上筆電,意味深長地看了我們一眼:「你們以為把 Token 搬進後端,天下就太平了嗎?」
我們愣住了。
「當 BFF 成為持有全公司使用者 Token 的金庫時,它就成了黑客眼中的黃金靶心。
下週一,你們馬上就會遇到更詭異的妖怪:
老張的話在空蕩蕩的辦公室裡迴盪。
原本以為已經解決的難題,似乎才剛剛揭開潘朵拉盒子的第一層蓋子……
(未完待續,請見 Day 2:【第一夜:隔壁老王的信用卡】剛把 Token 藏好,用戶怎麼全變成別人了?)