AI 加了一条绕过鉴权中间件的路由 —— 排查与修复

AI 在鉴权路由组之外注册了新接口,把内部数据无防护暴露出去。最快的修法:把路由迁到鉴权 router 下,并在数据层(而非中间件)强制 auth。

你让 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.userundefined原因 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.userundefined,在乐观分支里 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.2513.5.912.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 公开”路由的标准模式。
  • 本应套上的中间件链(requireAuthrequireAdmin 等)。
  • 最近加路由的 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