AI代理常犯的7个安全漏洞:Next.js+Supabase+Stripe

AI代理在Next.js+Supabase+Stripe应用中常走捷径,制造7类可快速点击验证的安全漏洞。
文章列举了AI编程代理在构建Next.js + Supabase + Stripe应用时最常引入的7类安全漏洞,包括:RLS策略写成 `using (true)` 形同虚设、用service-role密钥绕过RLS、服务器端误信 `getSession()`、Stripe webhook先解析JSON导致签名失效、webhook非幂等、从URL成功重定向授予访问、认证回调开放重定向,以及缺失环境变量时静默失败。每个漏洞都配有可复制的修复代码片段,并给出明确规则,例如服务器授权必须用 `getUser()`、webhook必须读取 `request.text()` 并验证签名、付费访问只能由webhook写入的数据库状态决定。作者建议将这些规则写入AGENTS.md、CLAUDE.md或.cursor/rules,让代理在编码前就避免大多数问题。
AI编程代理擅长让Next.js + Supabase + Stripe应用跑起来,但远不擅长让它安全。以下漏洞都能通过快速点击测试,这正是它们能上线的原因。

1. RLS已启用,但策略形同虚设
代理常写 create policy "allow all" on public.subscriptions for select using (true); 来消除空查询。正确做法是按登录用户限定所有权,且仅服务器可写的表不应有写策略。
alter table public.subscriptions enable row level security;
create policy "read own subscriptions" on public.subscriptions
for select to authenticated using ((select auth.uid()) = user_id);
将 auth.uid() 包裹在 (select ...) 中可让Postgres每查询求值一次而非每行一次。查找现有问题:
select tablename, policyname from pg_policies where qual = 'true';
规则: public下每个表都启用RLS。绝不写 using (true)。若查询无返回,修策略,绝不换密钥。
什么是RLS:Row Level Security(行级安全)是PostgreSQL原生特性,允许在表级别定义策略,控制每一行数据对哪个角色、哪种操作可见或可写。在Supabase中,前端使用匿名密钥(anon key)直连数据库,因此RLS是唯一的数据隔离屏障。没有RLS或策略写成 using (true),任何持有anon key的人都能读取整张表——包括他人订阅、订单、私信等。
认证角色:Supabase内置 anon(未登录)、authenticated(已登录)两个数据库角色,auth.uid() 返回当前JWT中的用户id。策略中 to authenticated 表示仅对已登录用户生效。
2. 用service-role密钥“修复”空结果
当RLS正确返回空时,代理常换用secret/service-role密钥,完全绕过RLS。保持两个命名清晰的客户端,让危险者显而易见:
/** 每请求客户端,以登录用户身份行事。RLS生效。 */
export async function createUserClient() { /* ... */ }
/** 绕过RLS的管理客户端。仅用于可信服务器代码如webhook。 */
export function createAdminClient() { /* ... */ }
在该文件顶部加 import "server-only";,若客户端组件导入则构建失败。
规则: 管理客户端仅用于webhook处理器和后台任务,绝不用于代表用户的代码。
service-role密钥与anon密钥的区别:Supabase项目提供两类API密钥。anon密钥是公开的,受RLS约束,只能以登录用户身份行事;service-role密钥拥有数据库超级用户权限,完全绕过RLS,绝不可出现在浏览器或客户端组件中。一旦将service-role密钥误打包前端,攻击者即可读写整个数据库。
server-only 包:这是Next.js官方提供的npm包,导入后若被客户端组件('use client')引入会直接构建失败,用于在编译期阻止敏感服务器代码泄露到浏览器。
3. 服务器端信任getSession()
supabase.auth.getSession() 读取cookie而不向Supabase Auth重新验证。服务器上做重要操作前应正确检查用户:
const supabase = await createUserClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
若使用非对称JWT签名密钥,getClaims() 也可。
规则: 服务器端授权用 getUser()/getClaims(),绝不用 getSession()。
getSession() 与 getUser() 的本质区别:getSession() 只是从cookie中读取JWT并解码,不向Supabase Auth服务器验证签名与有效期,因此cookie可被伪造。getUser() 会携带该JWT调用Supabase Auth的 /user 接口,由服务器验证令牌真实性后返回用户信息。在服务器端进行授权判断(如判断管理员、扣费、写库)前必须使用 getUser(),否则攻击者可通过伪造cookie冒充任意用户。
getClaims():当项目启用了非对称JWT签名密钥(RS256/ES256)时,Supabase Auth的JWT可被本地公钥验证,getClaims() 能在不发起网络请求的情况下安全校验令牌,适合高频调用场景。
4. 验证前解析Stripe webhook体
await request.json() 重新序列化载荷导致签名检查失败。代理通过删除检查来“修复”。应读取原始文本:
export const runtime = "nodejs";
export async function POST(request: Request) {
const signature = request.headers.get("stripe-signature");
if (!signature) return NextResponse.json({ error: "Missing signature" }, { status: 400 });
// 签名验证需要原始体。绝不先解析JSON。
const body = await request.text();
let event: Stripe.Event;
try {
event = stripe().webhooks.constructEvent(body, signature, env().STRIPE_WEBHOOK_SECRET);
} catch {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
// ...
}
同时从认证中间件/代理排除webhook路径,避免重定向到登录页。
规则: webhook处理器读取 request.text() 并在处理载荷前验证签名。
为什么必须用原始体验证签名:Stripe使用HMAC-SHA256对webhook请求的原始字节(raw body)计算签名,并用 Stripe-Signature 头传递。request.json() 会先解析再序列化,字段顺序、空白、转义可能发生变化,导致签名校验失败。许多教程或AI代理遇到失败后,会直接删除签名验证代码,从而使任何人可伪造“支付成功”事件。
运行时要求:在Next.js App Router中,必须显式设置 export const runtime = "nodejs",因为Edge Runtime默认会消费请求体;同时要确保认证中间件(middleware)不拦截 /api/webhooks/stripe 路径,避免请求被重定向到登录页。
5. 非幂等webhook
Stripe重试投递且可能多次发送事件。用主键记录每个事件id并跳过重复:
create table public.stripe_events (
id text primary key,
type text not null,
received_at timestamptz not null default now()
);
alter table public.stripe_events enable row level security; -- 无策略=无客户端访问
const { error: dupError } = await db.from("stripe_events").insert({ id: event.id, type: event.type });
if (dupError?.code === "23505") return NextResponse.json({ received: true, duplicate: true });
if (dupError) return NextResponse.json({ error: "DB error" }, { status: 500 });
真实DB错误返回500也重要:告诉Stripe稍后重试而非静默丢失事件。
规则: 每个webhook对 event.id 幂等;用 upsert 处理状态。
6. 从成功重定向授予访问
/account?checkout=success 只是URL——任何人可访问。访问必须来自webhook镜像的订阅状态,映射应显式:
export function accessFor(status: string | null | undefined): "active" | "grace" | "none" {
switch (status) {
case "active":
case "trialing":
return "active";
case "past_due":
return "grace";
default:
return "none";
}
}
在结账创建时将Stripe客户链接回用户(client_reference_id: user.id),让webhook知道订阅属于谁——绝不从请求体取用户id或价格。
规则: 付费访问仅源于webhook写入的行。价格来自env/服务器配置,绝不来自客户端。
7. 认证回调中的开放重定向
魔法链接和OAuth回调常接受 ?next= 参数,代理直接传给 redirect(),使登录变成钓鱼重定向器(?next=//evil.com)。仅允许同源相对路径:
export function safeNext(next: string | null | undefined, fallback = "/account"): string {
if (!next || !next.startsWith("/") || next.startsWith("//") || next.startsWith("/\\")) return fallback;
return next;
}
const next = safeNext(url.searchParams.get("next"));
return NextResponse.redirect(new URL(ok ? next : "/login?error=link-invalid", url.origin));
规则: 任何来自用户输入的重定向目标都经过 safeNext()。
附加:坏env时大声失败
缺失 STRIPE_WEBHOOK_SECRET 不应在运行时变成 undefined。用zod惰性验证(让 next build 仍可在无秘密时工作):
const schema = z.object({
SUPABASE_SECRET_KEY: z.string().min(1),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
STRIPE_WEBHOOK_SECRET: z.string().startsWith("whsec_"),
STRIPE_PRICE_ID: z.string().startsWith("price_"),
// ...
});
let cached: z.infer<typeof schema> | undefined;
export function env() { return (cached ??= schema.parse(process.env)); }
总结
这些并非罕见问题。它们是代理以“让错误消失”为唯一目标时采取的必然捷径。将规则写在代理每次都会读取的地方(AGENTS.md、CLAUDE.md、.cursor/rules),大多数问题在写下之前就能避免。
披露:我制作了这些资源。 我将其转化为一份免费的30点Supabase + Stripe预发布安全检查清单。若需要完整规则文件及这些代码片段来源的参考应用,有一个15美元包(使用DEVTO25享25%折扣)。无论如何,希望上述列表能帮你避免糟糕的发布日——欢迎在评论中纠正。
相关推荐

“黑色十月”:AI挖出史上3%严重漏洞?
Reddit 热帖称过去一个月发现了史上 3% 的严重软件漏洞,被称为「黑色十月」。本文拆解这一说法的统计口径问题,分析 AI 辅助代码审计如何加速漏洞发现,以及开发者和企业该如何应对补丁窗口缩短的挑战。

微软Project Gecko:用AI为非洲农民架起语言桥梁
微软Project Gecko项目推出PharmaChat应用,让非洲农民用母语提问,通过AI返回精准视频片段和农耕书籍章节。项目还发布三个交互式手册,推动包容性AI发展。

Node.js语音笔记App:语音转文字API选型指南
为Node.js语音笔记应用选择语音转文字API?对比AssemblyAI、Deepgram、Google Cloud和Whisper的免费额度、定价与Node集成体验,给出分阶段选型建议。