你让 Cursor 在 /api/admin/export-users 加个接口。它生成 handler,挂在 Express app 的最顶层,返回 200 加全量用户列表,在 dev 里跑得完美——因为 dev 根本没有 auth。到了 staging,有人不登录就敲这个 URL,拿到所有用户的邮箱。这条路由被注册在你 requireAuth 中间件边界之外。AI 没看到边界,它只看到”加一条路由”,然后加在它眼里”路由通常在的地方”。这是 P0 级安全事故,而且会发生,是因为鉴权边界通常是结构性的(某个 router 挂了中间件 X,另一个没挂),而不是 AI 可以模式匹配的逐路由声明。
最快的修法: 找到新路由的注册那一行,把它迁到已经挂了 requireAuth 的 router 下(不要把鉴权写进 handler 体),再加一条集成测试:不带凭据请求该路由,断言返回 401。然后用同样方式审一遍本次 PR 里其它新增路由。如果真实用户可能已经访问过,就按事故处理:审查访问日志,轮换一切被暴露的东西。本文余下部分讲怎么找到缺口、在各类框架下把它堵上,以及怎么让 AI 别再犯。
你属于哪一类
先跑一项检查,再跳到对应的原因。
| 你看到的现象 | 最可能的原因 | 跳到 |
|---|---|---|
新路由在根 app 上用 app.get(...) / app.post(...) | 注册在根上,而非鉴权 router | 原因 1 |
| 新路由文件在你的受保护目录约定之外 | 放在了自动加载的鉴权目录之外 | 原因 2 |
diff 删掉了 requireAuth 的 import 或 app.use(requireAuth, ...) 那行 | 为消掉 dev 的 401 而删了中间件 | 原因 3 |
handler 读 req.user,但上游没有中间件给它赋值 | 跳过了 auth 加载步骤,req.user 是 undefined | 原因 4 |
Next.js / Remix / Astro 里新文件在 app/(authed)/... 之外 | 没遵守路由组约定 | 原因 5 |
鉴权只写在 middleware.ts(Next.js) | 中间件不是安全边界(见 CVE-2025-29927) | 原因 5 |
OPTIONS / CORS handler 在 auth 之前就返回了 body | 预检短路提前泄漏数据 | 原因 6 |
webhook 用 secret header,但用 == 比较 | secret 校验朴素或可被绕过 | 原因 7 |
常见原因
按真实审计中的频次排序。
1. AI 把路由注册在了根 app 上,而不是鉴权 router 上
你的 Express 结构是:
app.use("/api/public", publicRouter);
app.use("/api", requireAuth, authedRouter);
app.use(adminRouter); // <- AI 把新路由加在这里
新的 admin 路由挂在裸 app 上,requireAuth 永远不会执行。
如何识别:新路由文件 import 了 app(或根 router),并直接 app.get(...),而不是用既有的鉴权 router。
2. AI 把路由文件放到了”受保护目录约定”之外
你的项目约定是:src/routes/authed/ 下的一切自动套 requireAuth;AI 在它隔壁建了 src/routes/export-users.ts,在那个目录外。auto-loader 收不到,AI 又看到类似 import 就手动挂——通常挂在顶层 app。
如何识别:新路由文件在不寻常的目录;它的 import 路径与同级路由不一致。
3. AI 删了某个中间件,以”修”dev 里的 401
接口在本地一直 401。AI 的诊断是”把挡测试请求的中间件去掉”,而不是”测试请求要登录”。结果这条路由在所有环境都不需要 auth。
如何识别:diff 里删除了 requireAuth 的 import,或路由注册里 app.use(requireAuth, ...) 那行没了。
4. AI 用 if (req.user?.isAdmin) 守了,但 auth 加载步骤被跳过
接口里检查 req.user.isAdmin,但 req.user 根本没被填——因为从 JWT 设置 req.user 的上游中间件就没被接上。没有 auth 的情况下 req.user 是 undefined,在乐观分支里 fail-close 没问题,但 AI 在另一个分支里又返回了默认值。
如何识别:代码引用了 req.user,但在该路由中间件链上游找不到任何 req.user = ...。
5. 在 Next.js / Astro / Remix 里没用对路由组
框架约定里 app/(authed)/admin/users/page.tsx 由 layout 强制鉴权,app/admin/users/page.tsx 不被强制。AI 建了第二种。
如何识别:新文件在 (authed)(或等价分组)之外;对应 layout.tsx 没有 auth。
Next.js 里还有一个更隐蔽的坑:AI 经常把唯一的鉴权检查放进 middleware.ts。截至 2026 年 6 月,Next.js 官方明确指出中间件不是安全边界——它跑在边缘,只负责路由和响应整形,不是最后一道防线(见 Next.js Data Security 文档)。这不是空谈:CVE-2025-29927(CVSS 9.1)允许攻击者发送一个 x-middleware-subrequest header 就完全跳过中间件,绕开所有只写在中间件里的鉴权检查。修复已随 Next.js 15.2.3(以及 14.2.25、13.5.9、12.3.5)发布,但架构层面的教训不变:真正的鉴权必须跑在 Route Handler、Server Action 以及仅服务端运行的数据访问层(Data Access Layer)里。
如何识别:某个受保护页面或 route.ts 的唯一鉴权检查在 middleware.ts 里;Route Handler 或 Server Action 本身没有 auth() 检查。同时确认你的 Next.js 版本不低于已修复版本——跑 npm ls next。
6. AI 加的 OPTIONS / CORS 预检处理把数据顺带返回了
某些 AI 建议会用对 OPTIONS 提前返回来处理 CORS。如果提前返回里塞了数据(罕见但确实有),响应就先于任何 auth 检查泄漏出去。
如何识别:OPTIONS handler 返回 200 且带 body;auth 中间件只对 GET/POST 跑。
7. AI 加的 webhook 或内部接口,共享密钥校验写错了
webhook handler 经常用 secret header 代替 session 鉴权。AI 实现里常见:用 == 而非 crypto.timingSafeEqual 比较 secret;把空 secret 当合法;header 缺失时直接放过(if (header) { check } else { allow })。
如何识别:新路由用了 secret header;比较朴素,或有”header 缺失则放行”的兜底。
开始之前
- 盘点应用所有公开路由:
grep -r "app\.\(get\|post\|put\|delete\|patch\)" src/以及你框架的等价命令。 - 把每条路由对应到它的鉴权中间件。链上无中间件的路由都是嫌疑对象。
- 如果已经泄漏:按安全事故处理——轮换泄漏的密钥、审计访问日志、按 IR 流程上报。
需要收集的信息
- AI 新加路由 handler 的文件路径。
- 路由注册代码——它是如何被挂进 app 的。
- 项目里”鉴权 vs 公开”路由的标准模式。
- 本应套上的中间件链(
requireAuth、requireAdmin等)。 - 最近加路由的 commit 都要逐个看。
- 这个无防护接口自上线以来的访问日志。
分步修复
按”先堵漏,再防再犯”排序。
第 1 步:如果在泄漏数据,立刻禁用这条路由
把注册注释掉,或在 handler 里返回 503,边调查边阻断:
// app.get("/api/admin/export-users", exportUsersHandler); // DISABLED 2026-05-24 — missing auth
或加一个临时 feature flag,默认关。
第 2 步:把路由迁回鉴权 router 内
重构:
// 错:
app.get("/api/admin/export-users", exportUsersHandler);
// 对:
adminRouter.get("/export-users", exportUsersHandler);
app.use("/api/admin", requireAuth, requireAdmin, adminRouter);
中间件链通过挂载点生效。handler 保持纯粹。
第 3 步:在正确的层做 deny-by-default 检查
Express 里,根级别的 guard 能拦住公开白名单之外挂载的一切。要把它注册在路由挂载之前,这样它在链中最先执行:
app.use((req, res, next) => {
if (!req.user && !req.path.startsWith("/api/public")) {
return res.status(401).json({ error: "auth required" });
}
next();
});
Next.js 里不要把 middleware.ts 当成唯一关卡。用中间件做一个廉价的”跳转到登录页”是可以的,但截至 2026 年 6 月官方口径是中间件不是安全边界(缺凭据的请求仍能到达路由——CVE-2025-29927 利用的正是这点)。真正的检查放在数据层。建一个仅服务端运行的数据访问层(DAL),让每个受保护的 Route Handler、Server Action 和 Server Component 都调用它:
// data/auth.ts
import "server-only";
import { cookies } from "next/headers";
export async function requireUser() {
const token = cookies().get("session")?.value;
const user = token ? await verifySession(token) : null;
if (!user) throw new Error("auth required");
return user;
}
// app/api/admin/export-users/route.ts
import { requireUser } from "@/data/auth";
export async function GET() {
const user = await requireUser(); // 即使中间件被跳过也会执行
if (!user.isAdmin) return new Response("forbidden", { status: 403 });
// ... 返回数据
}
因为检查跑在 handler 内部,哪怕 AI 把路由挂在了保护组之外——甚至请求带着伪造的 x-middleware-subrequest header——也会 fail-close。页面级检查不会延伸到其中定义的 Server Action,所以每个 action 内部也要重新校验。
第 4 步:把 req.user.isAdmin 检查换成有类型的 helper
// src/lib/auth.ts
export function requireAdmin(req: Request, res: Response, next: NextFunction) {
if (!req.user) return res.status(401).json({ error: "auth required" });
if (!req.user.isAdmin) return res.status(403).json({ error: "admin required" });
next();
}
路由文件里:
adminRouter.get("/export-users", requireAdmin, exportUsersHandler);
handler 自身不写鉴权逻辑,AI 也就更难悄悄改坏。
第 5 步:为每条受保护路由加一条安全测试
describe("admin endpoints require auth", () => {
const adminRoutes = ["/api/admin/export-users", "/api/admin/users", "/api/admin/billing"];
for (const route of adminRoutes) {
test(`${route} returns 401 without auth`, async () => {
const res = await request(app).get(route);
expect(res.status).toBe(401);
});
}
});
AI 加新 admin 路由时,把它加进列表。一旦路由绕开了 auth,这个测试立刻挂。
第 6 步:对路由文件做静态检查
简单的 grep 或自定义 AST 检查:
# CI 脚本里
git diff --name-only main | grep "src/routes/" | while read f; do
if ! grep -q "requireAuth\|publicRoute" "$f"; then
echo "ERROR: $f does not declare auth posture"
exit 1
fi
done
强制每个路由文件显式声明 auth 立场。什么都不说的文件 PR 阶段就被拦下。
第 7 步:用规则文件教 AI
.cursorrules / CLAUDE.md:
## Auth posture (required for any new route)
- All routes default to authenticated. Never register a route on the root app
unless it is for /api/public/* or /api/webhooks/* (which use signature verification).
- Use the existing `adminRouter`, `authedRouter`, or `publicRouter` — do NOT
create a new top-level router.
- Auth checks live in middleware, never in handler bodies.
- When adding a new admin route: file goes in src/routes/admin/, mounted under
adminRouter, which is mounted with requireAuth + requireAdmin.
- Webhook endpoints use src/lib/webhook-verify.ts — never roll your own
signature comparison.
Before adding a route, restate the auth posture in your plan.
怎么确认已修好
- 不带 auth 命中新接口,本地和 staging 都返回
401。用命令行确认,别只看浏览器:curl -i https://staging.example.com/api/admin/export-users应显示HTTP/1.1 401。 - auth 测试套件包含了新路由,且全部通过。
grep显示 handler 只挂在鉴权 router 下,根 app 上没有。- 在 Next.js 上,鉴权检查能扛住伪造的中间件 header:
curl -i -H "x-middleware-subrequest: middleware" https://staging.example.com/api/admin/export-users仍返回401/403。用npm ls next确认版本已打补丁(15.x 上不低于15.2.3)。 - 修复前的访问日志已审过;任何未授权访问都按 IR 流程跟进/上报。
- 静态检查 / lint 规则能拦下未来不声明 auth 立场的路由。
长期预防
- 在真正强制鉴权的那一层做 deny-by-default:Next.js 用仅服务端的数据访问层(DAL),Express 用根 guard 加分层 router。不要把边缘中间件当边界。
- 每个 auth tier 只有一条权威 router(public、authed、admin)。新路由挂到既有 router,而不是新建。
- 把鉴权集中到可复用的 helper / 中间件里,绝不写成 handler 体内的条件。AI 经常重写 handler 体,但很少重构 helper 链。
- 新路由文件必须有一行
// auth: <public|authed|admin>注释,lint 强制。 - 每季度跑一次路由审计:
grep -r "router\.\(get\|post\)" src/,确认每条都映射到已知 auth tier。 - 保持框架依赖打补丁——像 CVE-2025-29927 这类中间件绕过靠升级修复,让 Dependabot/Renovate 提 PR 并尽快合并。
- AI 生成的路由改动必须在同一 PR 里附带安全测试,没测试不合并。
常见误区
- 相信 AI 那句”我加了鉴权”,但没检查路由注册位置——它可能把检查写在了 handler 里,位置错了。
- 默许 AI 自创顶层
app.use(...),而不是用既有鉴权 router。 - 把 dev 那种宽松鉴权(“dev 里人人是 admin”)当成 AI 可以依赖的事实。测试得对接近 staging 的 auth。
- 只 review handler 的 diff,跳过路由注册的 diff。bug 几乎都在注册,不在 handler。
- 允许 AI 在单文件里临时定义
requireAuth。auth helper 应在src/lib/auth.ts(或等价位置)并被处处 import。 - 忘了同时保护 HEAD、OPTIONS 等少被测试的方法。
相关问题见 AI 删掉了能跑的逻辑、AI 测试通过但功能其实坏了、AI 回滚改动。
FAQ
Q:AI 的 handler 里检查了 req.user.isAdmin,够了吗?
不够。如果填 req.user 的鉴权中间件根本没跑,req.user 是 undefined,你的检查就取决于 undefined 分支怎么处理。auth 必须在 handler 之前跑——放中间件,不要放 handler 体。
Q:我们用 session cookie,浏览器自动带,AI 加的路由应该没问题吧?
只有当读取 cookie、验证 session 的中间件对该路由跑了才行。路由挂在哪里决定哪些中间件跑。AI 在这点上静默出错。
Q:怎么审计 AI 历史 commit 里的同类 bug?
git log --diff-filter=A --name-only src/routes/ 列出所有新加路由文件。每一个都核对注册位置在鉴权 router 之下。30 分钟就能筛出最严重的。
Q:要不要直接禁止 AI 碰 auth 代码?
太死板——AI 写 handler 体和测试是好帮手。可以限制它修改中间件链和路由注册文件,要求显式 review。把这些文件加进 .cursorignore,或要求 maintainer review label。
Q:AI 给我建了个用 secret header 校验的 webhook 接口,我要盯什么?
三个经典 bug。第一,用 == 或 === 比较 secret,会泄漏时序信息;改用 crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)),并防住两个 buffer 长度不等的情况(那会抛异常)。第二,校验之前先被 express.json() 解析了 body,导致你哈希的字节和 provider 签名的不一致——要对原始 buffer 校验(express.raw({ type: "application/json" }))。第三,header 缺失时直接落到”放行”分支;签名缺失或为空必须 fail-close。再加一个时间戳窗口检查来挡重放。
Q:路由现在挂在 auth 后面了,但任意登录用户还能读到别人的数据吗?
那是授权(authorization),不是认证(authentication),AI 经常漏掉这块。一个 handler 如果 db.post.findUnique({ where: { id } }) 却不检查 post.authorId === user.id,就是 Insecure Direct Object Reference(IDOR,不安全的直接对象引用)。确认 auth 已经跑之后,还要核对每个 handler 是否检查了调用者拥有或有权访问那个具体资源。见 OWASP IDOR 防护 cheat sheet。
标签: #排查 #AI 编程 #安全 #auth #middleware