到底哪里出了问题
Supabase 通过自动生成的 REST API 暴露你的 Postgres 表。你的前端使用 anon key(也叫 publishable key)与这个 API 通信。这个密钥本来就是公开的:它随 JavaScript 包一起发布,任何人都能在 DevTools 里看到。
这种设计没有问题,前提是每张表都开启了 Row Level Security(RLS)并配置了合理的策略。RLS 决定某个请求可以看到哪些行。正如 TheCyberSecGuru 所指出的,引用 Supabase 自己的文档:“没有 RLS,任何持有你 anon key 的人都能访问所有数据。”
所以出问题的链条是这样的:
- AI 工具(或一个疲惫的人)生成了 create table profiles (...)。
- 没有人加上 enable row level security。
- 应用在测试中运行得完美无缺,因为这个 bug 恰恰在于权限太宽松。
- 任何人只要从你的 JavaScript 包里复制出 anon key,就能对这张表执行 select *。
如果你想了解 RLS 的工作原理,我们的 Supabase Row Level Security 指南 讲解了基础知识。下面我们专注于测试和修复。
如何检查你的应用(5 分钟)
1. 像研究人员那样从外部查看。打开你的线上网站,打开 DevTools,在已加载的 JavaScript 中搜索 supabase.co。你会找到项目 URL,旁边就是 anon key。然后在终端里,向一张你认为是私有的表请求一行数据:
curl "https://YOUR-PROJECT.supabase.co/rest/v1/profiles?select=*&limit=1" \
-H "apikey: YOUR_ANON_KEY" \
-H "Authorization: Bearer YOUR_ANON_KEY"响应说明了什么:
- 空数组 [] 或权限错误:很好,匿名用户无法读取这张表。
- 返回了真实数据行:你和那 7,403 个网站有同样的问题。
对每一张存放用户数据的表重复这个操作:profiles、users、messages、orders、bookings、leads、waitlist 等等。候补名单和联系表单的表很容易被遗忘,而它们几乎总是包含电子邮件。
既然已经在查看 JavaScript 包,顺便确认你找到的是 anon key 而不是 service_role key。service role 会完全绕过 RLS。如果它出现在你的前端,就当它已经泄露并立即轮换:你包里的任何内容都能被每位访客和页面上的每个第三方脚本读取,Polymarket 的用户已经领教过了,而且机器人早已在搜寻泄露的密钥。我们关于零停机轮换应用密钥的文章讲解了轮换方法,暴露在客户端 JavaScript 中的 API 密钥则介绍了还有哪些东西容易混进包里。
2. 从内部检查。在 Supabase SQL 编辑器中,列出 public schema 中的每张表及其是否开启了 RLS:
select tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by rowsecurity, tablename;任何 rowsecurity = false 的行都可以用 anon key 读取(甚至可能写入)。Supabase 控制台中的 Security Advisor 也会标记这些表。请认真阅读它的警告,而不是直接忽略。
3. 留意“RLS 已开启,但仍然敞开”的情况。开启 RLS 并不是终点。列出你的策略:
select tablename, policyname, roles, cmd, qual
from pg_policies
where schemaname = 'public';注意那些适用于 anon 或 public、且 qual = true 的 select 策略。这种策略等于在说“所有人都能读取所有内容”,和没有 RLS 一样。AI 工具有时恰恰会生成这样的策略来让报错消失。
如何修复
为每张表开启 RLS:
alter table public.profiles enable row level security;
alter table public.messages enable row level security;
-- repeat for every table in public开启 RLS 但没有任何策略时,表是锁死的:anon key 什么也拿不到。这是一个安全的默认状态。现在只添加你真正需要的访问权限。
编写范围精确的策略。一个典型的“用户只能查看和编辑自己的行”的配置:
create policy "Read own profile"
on public.profiles for select
to authenticated
using ((select auth.uid()) = user_id);
create policy "Update own profile"
on public.profiles for update
to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);需要牢记的几点:
- 把范围限定为 authenticated 而不是 public,除非数据确实是公开的(博客文章、商品列表)。
- 读写分开。一张表可以安全地公开读取,但插入操作仍然需要严格限制。
- 公开的表仍会泄露列。如果 profiles 表为了展示用户名而设为公开,那同一张表里的 email 或 phone 列也同样是公开的。把私有字段移到单独的表中,或者只通过一个包含安全列的视图来暴露。
- 视图可能绕过 RLS。在 Postgres 中,视图默认以所有者的权限运行。创建视图时设置 security_invoker = true,这样调用者的 RLS 才会生效。
- Storage 是单独的。公开的存储桶会把文件提供给任何拿到 URL 的人。上传的证件、发票以及私密用户的头像应放在私有存储桶中,并配置存储策略。
把它变成默认做法,而不是事后清理。研究人员的样本覆盖了十个月内的新应用,这意味着每建一张新表,这个问题就可能再次发生。让人很难忘记它:
- 在 AI 工具的项目说明中加一条规则:public 中的每个 create table 之后都必须跟上 enable row level security 和明确的策略。
- 合并之前,检查生成的迁移文件中是否有 create table 却没有对应的 enable row level security。
- 每次修改 schema 后都重新从外部检查一遍,而不只是在上线时检查。
如果你用的是 Firebase 而不是 Supabase,那里也存在同类错误,表现为开放的安全规则。参见 Firebase Security Rules:别再让你的数据库敞开大门。
如果你发现了暴露的数据
修复策略可以阻止今后的读取,但无法撤销已经发生的读取。诚实地评估哪些数据曾经可被访问、持续了多久。检查 Supabase API 日志中是否有来自陌生 IP 的异常批量读取,轮换存放在可读表中的所有密钥;如果电子邮件、证件或支付信息等个人数据遭到暴露,请查明你的通知义务。在欧盟,这很可能意味着 GDPR 的数据泄露规定适用。
快速检查清单
在宣布完成之前,请确认:
- public 中的每张表都是 rowsecurity = true。
- 存放私有数据的表上,没有任何 select 策略使用 using (true)。
- 策略范围限定为 authenticated,并与 auth.uid() 绑定。
- 私有列不在可公开读取的表中。
- 视图使用 security_invoker = true。
- 前端中只有 anon key,绝不能有 service_role。
- 私有文件放在私有存储桶中。
- 每次修改 schema 后都从外部重新测试。
从外部检查你的应用
我们的 AI 构建应用安全检查清单 涵盖了上线前值得检查的其他事项。这项研究令人不安的地方在于方法有多普通:公开密钥、只读请求,数据就返回了。这正是攻击者看到的视角,也是你应该定期检查的视角。
AppSafe 从外部扫描你的线上应用。经过验证的完整扫描包括对开放的 Supabase 和 Firebase 数据库的只读检查,并查找暴露在 JavaScript 包中的 API 密钥;定时扫描还能在部署后出现变化时向你发出警报。想知道报告长什么样?看看这份示例报告。
立即免费扫描你的应用,在别人之前弄清楚陌生人能看到什么。
