// 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 中,除非你有明确理由 (营销资产、无 PII 的公共头像),否则将 Public bucket 开关设为关闭。公共桶对读取操作绕过 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 编码工具默认生成仅客户端验证;那什么都保护不了。
- 在服务端从文件的实际字节而非
Content-Type标头重新检查 MIME 类型。使用像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 配置错误扫描器。
