文章指出 AI 编辑器可能把管理员角色存入用户可修改的 user_metadata,并让 RLS 策略和页面权限信任该字段,导致普通用户自行提权。建议改用仅服务端可写的 app_metadata,或禁止客户端写入的角色表。
AI 编辑器总是把 Supabase 用户的角色放进 user_metadata,再根据这个字段决定用户能否通过管理员 RLS 策略,以及访问管理员页面。
user_metadata 可以由用户自行修改。在浏览器控制台调用一次 supabase.auth.updateUser({ data: { role: 'admin' } }),任何人都能把自己变成管理员。
把角色存进 app_metadata(仅服务端可写),或者存进没有客户端写入策略的角色表,然后检查这些位置中的角色。
让 Cursor 或 Claude Code 给 Supabase 应用添加一个管理员控制台,你往往会得到一套看起来很规整的实现:注册时设置 role 字段,用 RLS 策略允许管理员读取所有订单,再通过 middleware 检查,把非管理员从 /admin 重定向出去。第一次运行,一切正常。
接着,用一个普通的非管理员账号打开浏览器控制台,运行一行代码。你就成了管理员。
不需要利用其他漏洞,也没有密钥泄露。只是角色被存到了 Supabase 明确允许用户自行编辑的位置。
问题出在:代码从 user_metadata 读取授权依据,而这个字段由已登录用户控制。下面是 AI 编辑器常见的写法,同一个问题同时出现在三个地方。
注册时,把角色存到用户数据中:
// signup.js
await supabase.auth.signUp({
email,
password,
options: { data: { full_name: name, role: 'user' } },
});
RLS 策略信任这个字段:
-- CWE-269: privilege decided by a value the user can set
create policy "Admins can read all orders"
on public.orders
for select
to authenticated
using ( (select auth.jwt()) -> 'user_metadata' ->> 'role' = 'admin' );
应用也信任它:
// middleware.js
const { data: { user } } = await supabase.auth.getUser();
if (user?.user_metadata?.role !== 'admin') {
return NextResponse.redirect(new URL('/', request.url));
}
整个攻击过程就是下面这两行。用任意已登录账号,在 devtools 中输入即可:
await supabase.auth.updateUser({ data: { role: 'admin' } });
await supabase.auth.refreshSession();
新的 token 现在携带了 role: admin。RLS 策略检查通过,middleware 检查通过,表中的所有订单都会返回。攻击者甚至不需要调用更新方法,直接把 role: 'admin' 传给 signUp,注册完成时就已经是管理员了。
Supabase 自己的文档对此说得很直接。RLS 指南指出,raw_user_meta_data「可以由已认证用户更新」,依赖 user_metadata claim 的策略「可能给你的应用带来安全问题」。Supabase 的数据库 linter 还为此提供了专门的检查项:0015 rls_references_user_metadata,级别为 ERROR。其理由是:用户 metadata「本来就是供用户自行修改的」,可以被改成「任意值(没有任何校验)」。
注册时把角色复制到 profiles 表,并不能解决问题。下面这个 trigger 是 AI 常见的「改进」:
insert into public.profiles (id, full_name, role)
values (
new.id,
new.raw_user_meta_data ->> 'full_name',
coalesce(new.raw_user_meta_data ->> 'role', 'user')
);
角色依然来自用户注册时提交的数据。如果 profiles 表还有常见的「用户可以更新自己的资料」策略,用户就能对自己的那一行执行 update profiles set role = 'admin',因为 RLS 检查的是你操作哪一行,而不是哪些列。
AI 编辑器把角色放进 user_metadata,是因为它是浏览器客户端唯一能写入的用户字段。代码使用公开的 anon key 运行,因此能用的工具就是 updateUser 和 signUp({ options: { data } })。写入 app_metadata 需要 admin API 和 service_role key,而这个密钥必须放在服务端,AI 可能还没有搭好这一部分。
教程也在引导开发者往这个方向走。注册示例把 full_name 和 avatar_url 存进 options.data,role 字段看起来不过是又一个用户资料属性。两个 metadata 对象又并排出现在 auth.jwt() 中,因此,读取 user_metadata 的策略看起来和读取 app_metadata 的策略一样合理。没有报错,编辑器也没有警告。管理员页面就这么正常工作了——谁想当管理员,谁都能用。
把角色存到只有服务端能写入的位置,并在那里检查角色。有两个不错的选择。
方案 1:app_metadata。用户无法更新它。在服务端路由或 Edge Function 中设置它,使用从非公开环境变量读取的 service_role key:
// server only. Never import this file in client code.
const supabaseAdmin = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_SERVICE_ROLE_KEY
);
await supabaseAdmin.auth.admin.updateUserById(userId, {
app_metadata: { role: 'admin' },
});
然后,让策略读取 app_metadata:
create policy "Admins can read all orders"
on public.orders
for select
to authenticated
using ( (select auth.jwt()) -> 'app_metadata' ->> 'role' = 'admin' );
Supabase 文档提到了一个需要权衡的地方:对 app_metadata 的修改,「在用户的 JWT 刷新之前,不会反映到 auth.jwt() 中」。也就是说,撤销管理员权限要等到其 token 刷新后才会生效,而不是立即生效。
方案 2:建立一张客户端无法写入的角色表。这个方案的变更立即生效,而且角色不用放进 token:
create table public.user_roles (
user_id uuid primary key references auth.users on delete cascade,
role text not null check (role in ('user', 'admin'))
);
alter table public.user_roles enable row level security;
create policy "Users can read their own role"
on public.user_roles
for select
to authenticated
using ( (select auth.uid()) = user_id );
-- No insert, update or delete policies: only service_role or SQL can change roles.
create function public.is_admin()
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
select exists (
select 1 from public.user_roles
where user_id = (select auth.uid()) and role = 'admin'
);
$$;
create policy "Admins can read all orders"
on public.orders
for select
to authenticated
using ( (select public.is_admin()) );
如果你在 profiles 中保留 role 列,就把它排除在用户可更新的列之外:
revoke update on public.profiles from authenticated;
grant update (full_name, avatar_url) on public.profiles to authenticated;
从注册时的 options.data 中移除 role,也要从所有将 metadata 复制到角色列的 trigger 中移除它。在 middleware 中,从 app_metadata 读取角色,或者查询数据库,绝不要从 user_metadata 读取。
验证时,用 options: { data: { role: 'admin' } } 注册一个新账号,再用这个账号的 session 查询一张仅管理员可访问的表。返回结果应该是零行。然后,用 grep 搜索这一模式:
grep -rn "user_metadata" src/ app/ supabase/migrations/
凡是用搜索结果中的字段来决定是否允许访问的地方,都是漏洞。在 Supabase 控制台中,Security Advisor 会对你的所有策略运行 0015 检查。
问:Supabase 用户能编辑自己的 user_metadata 吗?答:能。任何已登录用户都可以通过 supabase.auth.updateUser({ data: { ... } }) 修改它,也可以在注册时通过 options.data 设置它。Supabase 文档明确指出,不应使用它做安全决策。
问:Supabase 中的 user_metadata 和 app_metadata 有什么区别?答:user_metadata 是用户可写的资料数据。app_metadata 无法由用户更新,需要使用 service_role key 通过 admin API 设置,因此适合存储角色、套餐和团队成员关系。
问:如果页面也检查 user_metadata,那么在 Next.js middleware 中检查它安全吗?答:不安全。两处检查读取的都是同一个由用户控制的值,因此攻击者能同时通过这两处检查。把角色移到 app_metadata 或受保护的表中,并在服务端和 RLS 中检查它。
我一直在用 SafeWeave 检查这类问题。它的仓库检查包含一条检测从用户 metadata 读取角色的规则(supabase-role-from-user-metadata),Supabase 集成还会每天获取 Supabase 自己的 security advisors 检查结果,这样新出现的策略问题就会自动浮现,不用我惦记着去查看。这些检查包含在 Cloud 套餐中。即使没有扫描工具,上面的 grep 搜索和新账号测试也能发现大多数此类问题。关键是,要在别人打开控制台之前发现它。
如果要采取进一步措施,可以考虑屏蔽此人和/或举报其滥用行为。