// docs / baas security / supabase storage
Supabase 儲存桶安全清單:22 項
Supabase Storage 是包覆 S3 相容儲存桶的薄層,加上與資料庫相同的資料列層級安全模型。這代表影響資料表的相同 RLS 陷阱也會影響檔案存取 — 加上一些當 AI 編碼工具串接上傳時會出現的儲存特有問題。本清單跨五個區段共 22 項:儲存桶設定、RLS 策略、上傳驗證、簽署 URL,以及營運衛生。每一項都能在 15 分鐘內驗證。
下方每一項都是必要的。關於底層 RLS 機制,請參閱 Supabase RLS 掃描器。關於與儲存相鄰的金鑰暴露類別,請參閱 暴露在 JavaScript 中的 Supabase 服務角色金鑰。
儲存桶設定
從正確的預設值開始。設定錯誤的儲存桶無論你的 RLS 是否正確都會洩漏檔案。
- 每個儲存桶預設設為私有。在 Supabase 儀表板 → Storage → Buckets,將 Public bucket 開關設為關閉,除非你有明確理由 (行銷素材、無 PII 的公開頭像)。公開儲存桶會對讀取操作繞過 RLS — 任何知道儲存桶名稱的人都能列出與下載。
- 為每個儲存桶設定嚴格的檔案大小限制。儀表板 → Bucket settings → File size limit。50 MB 是使用者上傳的合理預設;對於影片 / 大檔案使用情境可刻意提高。沒有限制,單次惡意上傳就能耗盡你的儲存配額或月度頻寬。
- 限制每個儲存桶允許的 MIME 類型。允許的 MIME 類型清單 — 明確的白名單,而非黑名單。僅圖片儲存桶使用
image/jpeg、image/png、image/webp。在使用者內容儲存桶中絕不允許text/html、application/javascript或image/svg+xml— 透過簽署 URL 提供時,它們會在瀏覽器中執行。 - 每種內容類型使用一個儲存桶,而非一個共用儲存桶。每儲存桶設定 (大小、MIME 類型、RLS 策略) 是你擁有的粒度。
user-avatars儲存桶、document-uploads儲存桶和public-assets儲存桶比一個混合儲存桶更易鎖定。 - 如果前端上傳,驗證 CORS 設定。若使用者直接從瀏覽器上傳到簽署 URL,儲存桶 CORS 必須列出你的生產來源。
*只對公開儲存桶可接受 — 絕不用於含使用者 PII 的儲存桶。
storage.objects 上的 RLS 策略
Supabase Storage 將檔案中繼資料儲存於 storage.objects 資料表。該資料表上的 RLS 控制誰能讀取、上傳、更新或刪除檔案。沒有 RLS,儲存桶的公開/私有旗標就是你唯一的保護。
- 確認 storage.objects 上的 RLS 已啟用。
SELECT rowsecurity FROM pg_tables WHERE schemaname = 'storage' AND tablename = 'objects';必須傳回true。Supabase 在新專案上預設會啟用它;請驗證它沒有被停用。 - 為私有儲存桶撰寫以
auth.uid()限定範圍的 SELECT 策略。CREATE POLICY "users_read_own_files" ON storage.objects FOR SELECT USING (auth.uid()::text = (storage.foldername(name))[1]);。慣例是將檔案存放在[user-id]/[filename]之下,並使用storage.foldername()從路徑中擷取擁有者。 - 撰寫強制相同路徑慣例的 INSERT 策略。
CREATE POLICY "users_upload_own" ON storage.objects FOR INSERT WITH CHECK (auth.uid()::text = (storage.foldername(name))[1]);。沒有 WITH CHECK,已驗證使用者就能上傳到另一名使用者的資料夾。 - 若應用程式支援檔案編輯或刪除,加入 UPDATE 與 DELETE 策略。每個命令都需要自己的策略。跳過 DELETE 意味著已驗證使用者無法刪除自己的檔案;跳過 UPDATE 意味著檔案覆寫會靜默失敗。
- 在兩個瀏覽器工作階段中測試跨使用者存取。以使用者 A 登入、上傳檔案、複製路徑。在另一個瀏覽器以使用者 B 登入,嘗試透過 REST API 取得該檔案。回應必須是
403或404,絕對不能是200。
-- Confirm RLS on storage.objects
SELECT rowsecurity
FROM pg_tables
WHERE schemaname = 'storage' AND tablename = 'objects';
-- SELECT policy: scope reads to the owning user's folder.
CREATE POLICY "users_read_own_files"
ON storage.objects
FOR SELECT
USING (auth.uid()::text = (storage.foldername(name))[1]);
-- INSERT policy: enforce the [user-id]/[filename] path convention.
CREATE POLICY "users_upload_own"
ON storage.objects
FOR INSERT
WITH CHECK (auth.uid()::text = (storage.foldername(name))[1]);上傳驗證
即使儲存桶有 MIME 與大小限制,也要在伺服器端驗證每次上傳。AI 編碼工具預設只產生用戶端驗證;那什麼都保護不了。
- 在伺服器端從檔案的實際位元組重新檢查 MIME 類型,而非從
Content-Type標頭。使用像file-type(Node) 之類的函式庫或魔術位元組嗅探。攻擊者可在實際是多語言 HTML / JavaScript 載荷的檔案上宣稱Content-Type: image/jpeg。 - 從上傳的圖片中移除 EXIF 中繼資料。EXIF 可能包含 GPS 座標、裝置序號與時間戳記。在儲存前使用
sharp的.withMetadata(false)或exif-parser移除。 - 拒絕包含
script標籤或onload處理器的 SVG。SVG 是 XML — 而許多 AI 產生的應用程式允許將 SVG 上傳作為「只是圖片」。在伺服器端使用DOMPurify或完全拒絕 SVG 上傳。 - 使用具決定性、不可猜測的檔名。不要保留原始檔名。使用 UUID 或檔案內容的雜湊。原始檔名會洩漏資訊 ("
passport_scan_2024_01_15.jpg"),可預測的名稱會讓列舉成為可能。
簽署 URL
簽署 URL 是用戶端存取私有儲存桶的方式。過期時間、儲存桶範圍與會被記錄的內容都很重要。
- 簽署 URL 預設過期時間為 1 小時或更短。Supabase JS SDK 的
createSignedUrl(path, expiresIn)接受秒數。絕不要使用像31536000(一年) 這樣的值 — URL 會變成永久的半公開連結。 - 絕不在資料庫中儲存簽署 URL。在每次請求時於伺服器端產生新的。儲存的、有 1 年過期的簽署 URL 若透過資料庫匯出洩漏,就會授予長期存取權。
- 記錄簽署 URL 的產生,而不僅是檔案上傳。如果之後懷疑遭入侵,你需要知道誰、何時、產生了哪個 URL。記錄
auth.uid()+ 儲存桶 + 物件路徑 + 時間戳記。 - 在提供使用者上傳的檔案時,使用
downloadAs選項。createSignedUrl(path, expiresIn, { download: '.jpg' })會強制Content-Disposition: attachment標頭,使檔案下載而非渲染 — 擊敗 HTML / SVG / PDF 內嵌 HTML 的執行類別。
營運衛生
儲存設定會隨時間漂移。下列四項營運項目能讓攻擊面保持緊縮。
- 每季稽核儲存桶。儀表板 → Storage → Buckets。確認公開/私有狀態與 MIME 類型清單符合應用程式預期。「臨時」建立的儲存桶如果沒人移除就會變成永久的。
- 監控匿名列出操作。儲存記錄 (儀表板 → Logs → Storage) 會記錄
LIST請求。針對私有儲存桶的匿名列出請求激增,意味著有人正從外部探測它。 - 為短期上傳設定保留策略。暫存儲存桶 (圖片預覽、草稿上傳) 應透過排程函式在 24-72 小時後自動刪除。在 GDPR / CCPA 的資料最小化義務下,無限期保留是一種責任。
- 每月執行 FixVibe 掃描。
baas.supabase-storage-public檢查會探測對匿名GET+LIST有回應的儲存桶。新儲存桶會被新增;舊儲存桶會改變可見性 — 只有持續掃描才能捕捉漂移。
後續步驟
對你的生產 URL 執行 FixVibe 掃描 — 匿名儲存清單會出現在 baas.supabase-storage-public 之下。將此清單與 Supabase RLS 掃描器 搭配用於資料表層,並與 暴露在 JavaScript 中的 Supabase 服務角色金鑰 搭配用於相鄰的金鑰暴露。關於其他 BaaS 提供者的儲存設定錯誤,請參閱 BaaS 設定錯誤掃描器。
