起因
MenkyoKit 是我们给在日外国人做的驾照考试学习站,Next.js 14 加 Supabase 加 Stripe,绝大部分代码由 AI 智能体在命令行里写。有一天看到一条视频:一个用 AI 搭的网站上线没几天,150 万条能接管账号的密钥、3 万多个邮箱、4000 条私信被人整个搬走,对方没用任何工具,只是右键看了源代码。
视频把 vibe coding 时代最常见的坑收拢成五个不用写代码就能做的检查。我们当天就把自己的站按这五条过了一遍,下面是过程和结果。
五个检查
| 检查 | 实测方法 |
|---|---|
| 一 · 数据库不登录能读吗 | 拿前端公开的 Supabase 密钥直接请求 REST 接口,逐张表读、逐张表写 |
| 二 · 右键看源代码有没有密钥 | 抓下首页引用的全部 JS 包(13 个,约 840 KB),搜 sk_live、whsec_、service_role、价格 ID |
| 三 · 有没有人能一直试、一直打 | 通读所有 API 路由,找限流代码 |
| 四 · 把网址里的数字加一 | 找所有按 ID 取数据的路径和接口,看是否只校验了「登录」而没校验「是不是你的」 |
| 五 · 无痕窗口不登录直接闯 | 不带任何 Cookie 访问后台、开发工具、账户页、付费页、各接口 |
结果
检查一 · 通过
八张表全部开着行级安全(RLS)。用匿名密钥读 purchases、entitlements、user_progress、mock_attempts、page_views、route_geometry、redeem_codes、admins,全部返回空数组;匿名写入被数据库拒绝(错误码 42501)。已登录用户的读写策略都是 auth.uid() = user_id,只能碰自己的行。激活码表、统计表、路线几何表干脆没有任何策略,只有服务端密钥能碰。
检查二 · 通过
JS 包里只有 Supabase 的 publishable 密钥。它本来就是设计给浏览器的,能做什么完全由 RLS 决定。Stripe 密钥、Webhook 签名密钥、服务端密钥、价格 ID 一个都没有。
一个小瑕疵:包里出现了 SUPABASE_SERVICE_ROLE_KEY 这个变量名。Next.js 不会把非 NEXT_PUBLIC_ 开头的环境变量打进前端,值是 undefined,不算泄露。但它说明读服务端密钥的模块被浏览器端代码引用了,最好拆开。
检查三 · 有缺口
代码里没有任何限流。三个公开接口都能被无限调用:访问统计信标可以被脚本无限写入;兑换激活码和创建 Stripe 结账会话虽然要登录,但登录之后没有次数限制。登录注册走 Supabase Auth 自带的限流,不经过我们的接口。
检查四 · 通过
全站没有按用户 ID 或订单号取数据的 URL。账户页在服务端只查当前登录者,数据库层再由 RLS 兜底;后台的授权、发码、删码每次调用都重新查一遍管理员表;路线编辑接口校验 ID 格式并且只对管理员开放;支付成功页不带会话 ID。
检查五 · 通过
不登录访问:后台三个页面和路线编辑接口返回 404(不是 403,不暴露存在);开发工具页和接口生产环境 404;账户页跳登录;结账和兑换接口 401;Stripe Webhook 无签名 400。付费课程和 50 题考试页在服务端就返回付费墙,网页和 RSC 数据里都没有课文和题目。
怎么补限流
不想为此接第三方服务,也不想让限流器一挂就挡住付款。最后做了两层。
内存层:每个 Vercel 实例自己维护一个 Map,按 IP 记最近一分钟的时间戳。零成本、零延迟,但实例之间不共享,一次洪水只是被实例数除了一下。给访问统计信标用,超出的请求静默丢弃,仍然返回 204,脚本看不出自己被限了。
数据库层:在 Supabase 建一张 rate_limits 表和一个 security definer 函数,一次 upsert 完成「窗口过期就重置,否则加一」并返回是否超限。函数只授权给 service_role,匿名和登录用户调用直接被拒。给兑换码和结账用,跨所有实例共享计数。
insert into rate_limits (key, count, window_start)
values (p_key, 1, now())
on conflict (key) do update set
count = case when rate_limits.window_start < v_cutoff then 1
else rate_limits.count + 1 end,
window_start = case when rate_limits.window_start < v_cutoff then now()
else rate_limits.window_start end
returning count into v_count;
return v_count <= p_limit;
阈值:统计信标每 IP 每分钟 30 次;兑换码每用户 10 分钟 10 次、每 IP 30 次;结账每用户 10 分钟 6 次。函数不存在或数据库连不上时放行,限流器故障不能变成付款故障。
上线后在生产库实测:同一个键、上限 2,三次调用返回 true、true、false;匿名密钥调用函数返回 permission denied;匿名读表返回空。
一个数字
整次自查加修复,从看完视频到线上验证通过,用了大约两个小时。检查本身几乎不需要写代码:一个 curl,一个 grep,一个无痕窗口。
给同样用 AI 建站的人
- 数据库开 RLS 只是开始,要用匿名密钥真的去读一遍每张表。
- 把前端 JS 包下下来 grep,别只看 HTML。
- 「要登录」不等于「有限制」,登录后的接口一样要限流。
- 受保护的页面要在服务端就拦住,别把内容塞进页面再用 CSS 藏起来。
- 限流器要设计成失败放行,否则它会成为你最脆弱的单点。
视频出处:小袋鼠KAKO《AI 做完的网站,直接上线安全吗?上线前你必须做的 5 个检查》。