iT邦幫忙

0

[BFF_Security] Day 1:BFF 到底在 B 什麼?從「API 跑腿小弟」到「全站 Token 大逃殺」

  • 分享至 

  • xImage
  •  

因為近期一些資安事件的影響,發現常見的 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-patterns.com):微服務拆得太碎、API 太吵,手機端淪為被虐得死去活來的「跑腿小弟」。
  • 資安的世界(RFC 10017 / BCP 212):瀏覽器是個四面漏風的篩子,把身分憑證放在前端,XSS 一來直接滿盤皆輸。

這兩份文獻明明來自兩個完全不同的平行宇宙,卻在今天傍晚狠狠撞在了一起。

就在全體準備通宵救火的絕望時刻,沉默許久的架構師老陳默默走上前,在白板中央重重畫了一個方塊,寫下三個大字:BFF(Backend for Frontend)。

https://ithelp.ithome.com.tw/upload/images/20261018/20104930OCa6IfkjeL.png


第一幕:前端為什麼會淪為微服務的「跑腿小弟」?

「老陳,我們的後端不是早就拆成先進的微服務了嗎?為什麼 App 還會慢成這樣?」小杰不解地問。

我嘆了口氣,在白板上畫出我們現在的架構:

https://ithelp.ithome.com.tw/upload/images/20261018/20104930kPu8fWTpf2.png

「看懂了嗎?」我對小杰解釋:「在微服務的世界裡,每個服務只管自己的一畝三分地:

  1. 網路瀑布流(Waterfall):手機在地下室訊號差,發送一個請求要 150ms。前端為了畫一個首頁,得按順序打 3 次 API,光是在空氣中等網路封包來回就耗掉了半秒!
  2. 資料肥大症(Over-fetching):後端的 User Service 是通用服務,回傳了整整 50KB 的大包 JSON(包含使用者地址、歷史標籤、甚至身分證號),但手機首頁其實只要使用者的暱稱!
  3. 重複造輪子:iOS 寫了一套代碼去把訂單陣列加總算最新金額,Android 又寫了一遍,Web 還得再寫一遍。
  4. 牽一髮動全身:後天 Order Service 的工程師把資料庫改個欄位名,全公司所有手機 App 立刻集體閃退!」

解法登場:請一個「專屬外帶打包員」

「早在 2015 年,串流音樂巨頭 SoundCloud 就遭遇過一模一樣的崩潰危機。」老陳接過話頭:「當時的工程主管 Phil Calçado 提出了這個模式:為特定端點打造專屬後端(Backend for Frontend,簡稱 BFF)。」

https://ithelp.ithome.com.tw/upload/images/20261018/2010493000E39f8oik.png

BFF 就像是派駐在後端機房裡的「外帶打包員」:

  • API Composition(聚合拼盤):手機只要敲門一次,BFF 在高速內網用 1ms 的速度把 User 和 Order 服務並發抓齊,組裝好一口氣送回手機。
  • API Lens / Tailoring(濾鏡裁剪):把 50KB 的肥大資料過濾掉,只把手機要的那 500 Bytes 裝進餐盒。
  • Protocol Translation(翻譯官):後端跑高效的 gRPC 或 Protobuf,BFF 轉成手機最愛的簡潔 JSON。

插曲:差點掉進「切太細」的大坑

「那太好了!」小杰興奮地說:「我們立刻開搞!iPhone 15 開一個 BFF、Android 開一個 BFF、iPad 再開一個!」

「停!千萬別!」老陳猛按暫停鍵,指著 bff-patterns.com 的警告:

「切記:不是『一台裝置一支 BFF』,而是『一種客戶端體驗(Experience Channel)』一支 BFF!」

https://ithelp.ithome.com.tw/upload/images/20261018/20104930sAU7a4NqGP.png

iOS 和 Android 的畫面 95% 是一樣的。如果你照硬體切,你只會養出兩份幾乎一樣、每天都要同步改 Bug 的代碼地獄!


代碼實戰:十分鐘搞定最小的 BFF

當天晚上,我立刻用 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 就回傳的乾淨資料,雙眼放光:「太神了!首頁效能問題搞定了!那我們是不是可以去吃火鍋了?」

「吃火鍋?」站在一旁的老張冷笑了一聲:「你們前端的效能是快了,但全公司的金庫大門剛剛已經被人卸下來搬走了!」


第二幕:全站大逃殺!Token 放在瀏覽器到底有多危險?

老張轉身打開投影機,螢幕上赫然是暗網論壇的截圖,上面正在兜售我們系統的 API Access Token 與 Refresh Token。

「這就是我們過去採用的純前端 SPA 裸奔架構的代價。」老張指著屏幕:

https://ithelp.ithome.com.tw/upload/images/20261018/20104930ChxNa7AqCa.png

老張敲了敲白板:

「這就是 IETF 剛出爐的 RFC 10017 (BCP 212) 最嚴厲的警告:
『在同一個 Origin(同源)內,不存在任何能阻擋惡意 JavaScript 的前端儲存空間!』
只要發生 XSS,攻擊者就能把 Token 『可帶走(Exfiltration)』!他把 Token 存進自己的 Postman,受害者就算把電腦砸了,黑客依然能日日夜夜用他的身分下單、轉帳、讀私密資料!」


第三幕:BFF 的華麗轉身——從打包員變成「機密金庫避難所」

「那如果把 Token 藏在 SessionStorage 呢?或者藏在記憶體變數裡?」小杰不甘心地問。

「RFC 10017 第 8 節早就把所有的瀏覽器儲存手段全都槍斃了一遍!」老張冷冷地說:

  • localStorage / sessionStorage / IndexedDB:同一網域下的惡意腳本一行代碼就能讀光。
  • 記憶體變數(In-Memory):惡意腳本只要竄改(Monkey-Patch)原生的 window.fetch,在 Token 發送的瞬間就能當場攔截!
  • Web Worker / Service Worker:惡意腳本能直接呼叫 navigator.serviceWorker.getRegistrations() 把你的 Worker 註銷掉,然後在背景重新走一次認證流程!

「那到底該把 Token 藏去哪裡?」我問。

老張指了指老陳剛才畫的 BFF 方塊:
「把 Token 徹底趕出瀏覽器!讓 BFF 來當 OAuth 的 Confidential Client!」

https://ithelp.ithome.com.tw/upload/images/20261018/20104930tbWEAxmYY9.png

靈魂比喻:要是被搶劫了怎麼辦?

老張用了一個無比精妙的比喻,點醒了在場的所有人:

  • 沒有 BFF(純前端 SPA):你把家門鑰匙(Token)放在朋友口袋裡。強盜在路上搶了朋友的包包,把鑰匙帶回自己家,以後三更半夜隨時能開門進你家搬東西。
  • 有 BFF:鑰匙鎖在大樓保全室(BFF)。朋友手裡只有一張「只有保全認得的臨時訪客貼紙(HttpOnly Cookie)」。強盜搶走包包,根本找不到鑰匙!
    • 但請注意——如果強盜拿槍抵著朋友的背(XSS 在線發請求),他確實能脅迫朋友當場走進保全室請保全開門。
    • 但只要朋友一脫身(關掉瀏覽器分頁),強盜就什麼也做不了!

BFF 的資安真諦:
它不可能 100% 消滅 XSS(那需要靠 CSP 和嚴格輸入過濾),但它做了一件價值連城的事:
把「可離線帶走、無限期洗劫的 Token 外洩」,成功降級為「只能趁使用者在線時當面脅迫的代理請求」!


第四幕:惡意 JavaScript 的四大暗黑招式(RFC 10017 §5 深度拆解)

看著大家若有所思的樣子,老張在白板上寫下了 RFC 10017 總結的黑客四大招數:

https://ithelp.ithome.com.tw/upload/images/20261018/20104930Ej1jkYbXCV.png

為什麼第 ③ 招「暗度陳倉」連最新潮的 DPoP 都防不住?

小杰忍不住舉手:「老張,我們前端界最近很紅的 DPoP(RFC 9449) 呢?它把 Token 綁定在瀏覽器的非對稱私鑰上,黑客就算偷走 Token 也沒私鑰可用啊!」

老張冷哼了一聲:「RFC 10017 第 5.1.3 節就是專門用來打臉這種天真想法的:

  1. 惡意腳本在背景悄悄建立一個隱形的外框 <iframe>,網址指向授權伺服器的 /authorize。
  2. 因為使用者剛登入過,授權伺服器認得瀏覽器的登入態,自動同意授權,回傳全新的授權碼 code。
  3. 惡意腳本攔截這個 code,用黑客自己產生的全新 DPoP 私鑰去換取一把全新的 Token!
  4. 既然這把新 Token 是用黑客自己的私鑰申請的,黑客當然能隨意使用! 前端費盡心思配置的 DPoP 在這一招面前瞬間變成擺設。」

「那 BFF 憑什麼能防住這招?」我問。

「因為在 BFF 架構下,OAuth 客戶端是機密客戶端(Confidential Client)!」老張敲著桌子:「要拿 code 去換 Token,除了出示驗證暗號,還必須出示只有 BFF 伺服器內網才知道的 client_secret!瀏覽器裡的惡意腳本沒有這把密鑰,換 Token 的請求在授權伺服器門口會被直接擊斃!」


第五幕:親眼見證——同一次 XSS,兩種架構的血淋淋對照

為了徹底驗證老張的推論,我們當場打開了專案裡的實驗代碼 day1/xsslab:

cd day1/xsslab && go run .
# 伺服器啟動在 http://127.0.0.1:8081

我們打開瀏覽器,分別造訪:

  1. /spa:模擬傳統 SPA 模式(Token 存放在 localStorage)
  2. /bff:模擬 BFF 模式(Token 鎖在伺服器,前端僅有 HttpOnly Cookie)

依序點下 Login,然後點下象徵惡意腳本被觸發的 Run injected script 按鈕:

https://ithelp.ithome.com.tw/upload/images/20261018/20104930y85wXV9isx.png

綠色的終端機螢幕上清晰地跳出兩組對照結果。
黑客確實沒辦法從 /bff 偷走任何 Token 字串,但最後一行的 request as victim => 200 {"user":"nathan"} 依然刺痛了小杰的眼睛:

「也就是說……就算我們用了 BFF,黑客趁使用者還在線的時候,依然能發請求?」

「沒錯,這就是真實世界的資安。」老陳點點頭:「資安從來不是非黑即白的童話。BFF 把原本『全公司使用者 Token 被拖庫、暗網兜售數月』的核彈級災難,成功壓制在『使用者關閉分頁即止血』的局部傷口。這就是架構設計的取捨與價值!」


尾聲:週五午夜的伏筆

時鐘指向深夜十一點半。小杰長舒了一口氣:
「懂了!我們下週就把架構切成 BFF!把 Token 全收回伺服器,發 HttpOnly Cookie 給前端!」

「等等,」老張合上筆電,意味深長地看了我們一眼:「你們以為把 Token 搬進後端,天下就太平了嗎?」

我們愣住了。

「當 BFF 成為持有全公司使用者 Token 的金庫時,它就成了黑客眼中的黃金靶心。
下週一,你們馬上就會遇到更詭異的妖怪:

  • 為什麼使用者明明輸入自己的帳號密碼,登入後卻赫然發現自己『變成了隔壁老王』,甚至幫老王刷卡買了東西?
  • 為什麼 Cookie 明明發下去了,使用者登入完跳回首頁,卻常常見鬼地顯示『未登入』?
  • 還有……你們在前端寫的 PKCE、State、Nonce,到底有幾個是真的在起作用,還是只是在自己騙自己?」

老張的話在空蕩蕩的辦公室裡迴盪。
原本以為已經解決的難題,似乎才剛剛揭開潘朵拉盒子的第一層蓋子……

(未完待續,請見 Day 2:【第一夜:隔壁老王的信用卡】剛把 Token 藏好,用戶怎麼全變成別人了?)


📚 本回參考文獻


圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言