2 小時黑箱稽核 multi-tenant SaaS:一份現場紀錄


最近交付一份 multi-tenant SaaS 的存取隔離稽核,僅針對 staging 環境。2 小時時間上限、Supabase 架構、不碰正式環境、沒有 service role 權限。客戶給了 5 個要回答的問題,報告寫成 7 段結構。

這篇把這 2 小時稽核攤開:5 個步驟怎麼跑、為什麼有些發現要降級、跟我刻意不做的事。


案子的條件

客戶把可以動的範圍劃得很嚴:

  • 2 小時時間上限。計時器開著,不能超時
  • 只測 staging。正式環境完全不碰
  • 沒有 service role key。跟外部攻擊者一樣的存取面
  • 沒有 Supabase Studio。不能讀 RLS policy SQL、不能 dump table、不能用管理員視角看 schema
  • 不做破壞性測試。只讀,不寫;要寫只寫自己擁有的帳號做的事
  • 報告 7 段結構:檢查了什麼、做了什麼測試、看起來沒問題的、找到的風險 (高 / 中 / 低)、跨 project 結論、無法在範圍內驗證的、單一最高優先建議

紙面上看這些限制很綁手綁腳,實際上它們逼出這份稽核唯一可行的做法:沒有 Studio、沒有 service role,等於只剩外部能觀察到的訊號可用,跟一個真實外部攻擊者的視角一樣。每個發現都要從可觀察的行為「推論」架構問題,不是從管理員視角直接讀出來。

這也是這份稽核要回答的問題:「目前開出去的存取權限,被人能拿來做什麼?」,不是「資料庫 schema 從管理員視角看是怎樣?」


方法:黑箱偵察 → 精準探測

步驟一:DevTools 偵察 (10 分鐘)

打開 staging app,DevTools 開著正常使用功能。Network tab 把每個 API 呼叫都記下來。

10 分鐘內我已經有:

  • PostgREST 端點形狀 (哪些 table 被查、哪些欄位被選、哪些 filter 參數)
  • Storage 結構 (bucket 名稱、路徑模式、signed URL 裡 token 的格式)
  • Edge Function 名單 (哪些被呼叫到)

沒文件、沒 schema dump、沒客戶內部協助。App 的行為就是文件,這也是攻擊者能拿到的東西。

步驟二:身份取得

接下來每個測試都依賴「你的 token 帶什麼 claim」。我以 User A 登入抓 JWT 存到 shell 變數,User B 同樣。訪客安裝連結用無痕視窗開,從 DevTools 的 Application → Local Storage 抓 JWT,解碼看 claims。

訪客 token 帶了一個自訂 claim:project_id: <PROJECT_A_ID> + guest_role: installer。這告訴我 RLS policy 是針對這個自訂 claim 寫的,不是只看 Postgres 內建 role。

這步看起來簡單,其實不是。稽核做失敗的案子,一半都是因為稽核員沒先明確列出「我現在以什麼身份測試?這身份帶什麼 claim?」就開始跑 query。

步驟三:跨身份 SELECT 測試

RLS 經典測試是鏡像查詢。以 User A 嘗試 SELECT Project B 的 row,以 User B 嘗試反方向,以訪客 token 對 3 個不同 table 嘗試跨 project SELECT。

RLS 寫得正確的話,每一個都應該回 []。空陣列才是對的:資料庫應該「假裝那筆 row 不存在」,而不是「擋下你並回 401」 (後者本身就是資訊洩漏)。

所有跨身份 SELECT 都回 []。PostgREST RLS 寫對的時候,看起來什麼都沒發生。

步驟四:Storage path-guess (最大發現)

Storage 是獨立於 PostgREST 的另一個把關層。資料庫 RLS 對,不代表 storage policy 對。

從 DevTools 抓到一條 app 真實在用的 signed URL,path 模板是:

<bucket>/<project_id>/<resource>/<resource_id>/<file>.jpg

Path-guess 攻擊:拿這個模板,把 project_id 換成另一個 project 的 ID,請 Supabase 簽 URL:

curl -X POST "$SUPA/storage/v1/object/sign/<bucket>/<PROJECT_B_ID>/<resource>/00000000-0000-0000-0000-000000000000/test.jpg" \
 -H "apikey: $ANON" \
 -H "Authorization: Bearer $USER_A_TOKEN" \
 -H "Content-Type: application/json" \
 -d '{"expiresIn":60}'

回應是 404 not_found不是 401 forbidden。

這個一字之差就是發現本身。401 等於「policy 拒絕簽這條 path 因為你不是 owner」;404 等於「policy 沒檢查 ownership,path 上沒檔案而已」,意思是只要你給的 path 上碰巧有檔案,policy 就會幫你簽。

真正擋住這條路徑的是「path 沒人能猜到」,不是 policy 真的在檢查 owner。

我在另外兩個 bucket 試了同模式,一樣回 404。這是系統性的漏洞,不是單一 bucket 的事。

我也抓了一條真實 signed URL 解開它的 JWT。iatexp 距離是 86,400 秒,也就是 24 小時。今天中午外洩的 signed URL,明天中午還能用。

任何登入用戶都能簽出任何 path、而且 signed URL 有 24 小時有效。這個組合是這份稽核裡影響範圍最大的發現。

步驟五:Edge Function 探測

這個 project 有 13 個 Edge Function。我以 User A 的 token 帶 Project B 的參數,探了其中 5 個。

generate-progress-report403 Forbidden: PM access required for this project。Function 自己做了權限檢查,service role 沒繞過 RLS,所以通過。

check-email-exists 不論 email 是否存在都回相同的中性訊息。標準的反列舉設計,所以通過。

google-maps-key{"apiKey": "AIza..."}。原始 key、無權限檢查。初判 HIGH。

第三個就是下一段的核心。


關鍵判斷:HIGH 降為 Medium

多數稽核到「找到問題」就停,沒再多走一步:驗證。

看到 google-maps-key 直接把 raw API key 吐出來的瞬間,第一個直覺是對的:這很糟。任何登入用戶,包括剛註冊低信任度的帳號,都能拿到 key。如果 key 用來打計費的 Google Maps API,這是直接的金錢風險。

但「key 暴露了」跟「key 可被利用」要分開評估。在標嚴重度之前,我再多送一次請求:

curl "https://maps.googleapis.com/maps/api/geocode/json?address=Tokyo&key=<leaked_key>"
{
 "status": "REQUEST_DENIED",
 "error_message": "API keys with referer restrictions cannot be used with this API."
}

這把 key 在 Google Cloud Console 已經設了 HTTP referer 限制。從後端打 (沒有 referer header),API 直接拒絕,這把 key 沒辦法拿來繞道燒 Google Maps 計費額度。

暴露路徑還在。攻擊者可以把 key 嵌到一個自己控制的 domain,只要 referer header 過得了限制,就能從瀏覽器發起呼叫。如果 referer 白名單設錯 (用 *.example.com 之類萬用字元),暴露範圍會擴大。如果未來改 config 把 referer 限制移掉,暴露立刻變可利用。

但這些都要其他條件配合才會發生 (referer 設錯、未來把 referer 限制拿掉),不是「下一小時內輪換」的等級。比較合適的分類是 Medium,配兩條具體建議:

  1. 移除這個 Edge Function 暴露路徑。 要麼在前端 HTML 直接 embed 這把有 referer 限制的 key,要麼後端 proxy 掉所有 Google Maps API 呼叫,前端永遠拿不到 key 本體。
  2. 驗證 Google Cloud Console referer 白名單是完整網域,不是萬用字元。

這是兩種完全不同的對話。「現在立刻輪換 + 稽核所有 client app」是一種,「移除多餘暴露 + 確認 referer 設定正確」是另一種。兩種都會讓系統更安全,但只有後者考慮到工程時間跟營運緊急度都是有限的。

重點就在這:發現只是起點,再驗證一次,才寫得出對的建議。

多數自動掃描工具跟跑清單式的稽核在「找到問題」就停下來,因為發現本身是可觀察的;判斷需要的是再驗一次、用真實環境試一次。這也是客戶自己不容易做的事,因此會願意花錢買。


我刻意不做的事

稽核做得好,一部分是技術,一部分是克制。經驗較淺的稽核員可能會交出的版本,會包含:

  • 一份掃描工具的輸出貼進附錄
  • 「Looks concerning」的發現但沒有驗證
  • 為了讓案子看起來值錢全面拉嚴重度
  • 建議從具體動作變成大方向的架構改造 (「實作 zero-trust storage policies」)
  • 明明還不確定的地方還用絕對的語氣下定論 (「系統對 X 攻擊脆弱」)
  • 計時器超過 2 小時上限還繼續跑

我交的版本沒做這些:

  • 24 小時的 signed URL 有效期是觀察值,跟 path-guess 那條一起看才有意義。沒另起一條 finding,因為它改變的是 path-guess 的影響範圍,所以併在同一條 Medium 裡。
  • 錯誤訊息洩漏資訊是真的,但是 Low。不會為了讓報告看起來內容多就拉到 Medium。
  • 計時器停在 1:51 / 2:00。報告本身是 off-clock 寫的,沒算進客戶的 2 小時 budget。合約寫的是 2 小時稽核,但要交給客戶的是一份能用的報告,這個差額我自己吸收。
  • 正式環境狀態沒在報告裡描述。稽核是 staging-only,每個對正式環境的描述前面都有「在範圍內無法驗證」這個前綴,因為這就是事實。
  • HIGH → Medium 的降級過程沒淡化。原始觀察、驗證步驟、重新分類的邏輯全寫進報告,讓客戶能跟著一起 audit 我的判斷。

「報告裡刻意不寫什麼」,就是認真做的稽核跟自動工具輸出最大的差別。


建議的寫法

只有發現沒有建議是雜訊;只有建議沒有發現是空話。每條發現的結構是:

  • 是什麼:具體風險,平實語言不講行話
  • 怎麼重現:用什麼身份、HTTP 方法、endpoint、body、看到什麼回應。讓客戶工程師能自己重現一次。
  • 影響的層:哪個架構元件負責 (RLS policy / storage policy / Edge Function),加上根因假設 (「推測簽發階段缺 path 層級的把關」)
  • 業務影響:具體業務後果,不是「資料外洩」這種空話。Storage 那條寫的是:「攻擊者一旦從任何其他管道拿到真實 Project B 檔案 path,就能簽 download URL 取得檔案;24 小時有效期延長 leak 後曝光時間。」
  • 建議:具體下一步動作,不是空泛的方向。寫「在簽 URL 階段檢查 path 是否屬於這個 project」是具體;寫「強化 storage 安全」就太空泛。

對「單一最高優先建議」這項,做法是:給一個建議,不是給一份清單。為什麼選這一條的理由要寫進去 (影響範圍、目前沒有緩解、不會牽動其他部分的修法)。這條之後的下一步順序也要點出來 (storage hardening 出貨後再去處理 google-maps-key cleanup)。

這部分的編輯工作跟技術工作一樣重要。客戶讀完報告,不只看到「找到了什麼」,還會看到「先做什麼、後做什麼,以及為什麼」。


收尾反思

1:51 的工時裡,技術工作佔 60% 努力,判斷工作佔 40%。

技術工作可以規模化。Path-guess 攻擊、跨身份 SELECT 鏡像、Edge Function token replay,一種攻擊模式認識一次,下次再看就認得。工具可以抓下這些模式,清單可以寫死這些步驟。

判斷工作不一樣。「對 HIGH 發現多花 5 分鐘驗證」的決定。「不為了報告好看膨脹 Low」的決定。「稽核是 staging-only 所以結論不講正式環境」的決定。「建議寫成可執行步驟,不是大方向」的決定。沒有一個是掃描工具的輸出,沒有一個是清單的項目。這些是讀過夠多稽核報告 (好的跟爛的) 才知道你自己這份正在變成哪種。

稽核交付物的標準不是「我有沒有找到東西」,是「找到的東西加總起來,客戶到底該做什麼、按什麼順序、哪些地方我還不夠確定、不該完全照我的話做」。


如果你也在做接案的安全稽核,或想找人幫你做類似的 multi-tenant SaaS 存取隔離檢視,歡迎找我聊聊


客戶識別資訊 (公司名、stack 細節、內部 ID) 刻意省略。本文程式碼範例使用佔位變數,測試的結構形狀跟實際執行的相同。