跳到主要内容
CodeAuditAgent
全部文章

Next.js App Router 安全检查清单

面向 Next.js App Router 应用的实用安全清单:服务端操作、路由处理器、中间件、环境变量、CSRF、响应头、webhook 和按用户缓存。

· 阅读约 8 分钟 · Lina Source LLC

App Router 把大量代码从浏览器挪回了服务端。这在安全上多半是好事:查询、密钥和业务逻辑现在跑在用户读不到的地方。但它也模糊了什么是公开端点、什么只是看起来像函数调用之间的界线。Next.js 应用中多数严重的漏洞都来自这种模糊。

这份清单涵盖我们在 App Router 代码库中最常见的问题,并按值得检查的顺序排列。其中没有什么稀奇的东西;每一条都是框架的便利掩盖了一条信任边界的地方。

1. 服务端操作就是公开端点

标记了 'use server' 的函数会被编译成一个 HTTP 端点。任何能打开你站点的人都能找到该操作的 ID 并用任意参数调用它,无论你的界面是否渲染过使用它的那个按钮。对非管理员隐藏一个表单,并不能保护它背后的操作。

每个操作都需要和任何 API 处理器一样的三个步骤:认证调用者、校验输入、对正在操作的具体记录做授权。请把这些校验放在操作体内部。渲染表单的页面组件里的校验只在页面加载时运行,而不是在操作被调用时运行,所以它什么也保护不了。

'use server';

import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

const Input = z.object({
  projectId: z.string().uuid(),
  name: z.string().trim().min(1).max(100),
});

export async function renameProject(formData: FormData) {
  const session = await auth();
  if (!session?.user) throw new Error('Unauthorized');

  const { projectId, name } = Input.parse({
    projectId: formData.get('projectId'),
    name: formData.get('name'),
  });

  // 所有权校验是写操作的一部分,而不是一次单独的查询
  const result = await db.project.updateMany({
    where: { id: projectId, ownerId: session.user.id },
    data: { name },
  });
  if (result.count === 0) throw new Error('Not found');

  revalidatePath('/projects');
}

留意那些导出很多操作的辅助文件。'use server' 模块中每一个被导出的函数都是可调用的,包括某人为内部脚本添加、随后又忘掉的那一个。

2. 路由处理器需要同样的校验

app/api 下的路由处理器更明显是端点,但它们有着同样的失效模式:用一个动态片段加载记录,却不检查谁拥有它。在较新的 Next.js 版本中 params 是一个 Promise;请 await 它、校验它,并把查询限定到调用者。

// app/api/documents/[id]/route.ts
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ id: string }> },
) {
  const session = await auth();
  if (!session?.user) {
    return Response.json({ error: 'unauthorized' }, { status: 401 });
  }

  const { id } = await params;
  const doc = await db.document.findFirst({
    where: { id, ownerId: session.user.id },
  });
  if (!doc) return Response.json({ error: 'not_found' }, { status: 404 });

  return Response.json(doc, {
    headers: { 'Cache-Control': 'private, no-store' },
  });
}

对调用者并不拥有的记录返回 404 而不是 403,这样端点就不会确认哪些 ID 存在。永远不要凭 params、searchParams、请求头或 Cookie 做授权决策;它们全都是攻击者可控的输入。

3. 中间件本身不是认证边界

中间件(在 Next.js 16 中更名为 proxy)适合用来重定向已登出用户和设置响应头。它不应是授权唯一发生的地方。matcher 很容易写错,新路由会被加在它们之外,而且服务端操作会 POST 到页面路径,这可能与你设想的模式不匹配。2025 年,CVE-2025-29927 表明一个精心构造的内部请求头可以让某些 Next.js 版本完全跳过中间件。

请把中间件当作一层便利设施。真正的校验属于数据旁边:放在每个操作和处理器里,或者更好的是放在一个所有服务端读取都会经过的数据访问层里。数据访问层是一个仅限服务端的模块,它暴露 getProjectForUser(projectId) 这样的函数,并在内部完成会话校验和所有权过滤。页面、操作和路由处理器调用它,而不是直接调用数据库客户端,这样新路由就不可能忘记校验:因为根本不存在可供遗忘的未校验路径。

4. 让服务端代码留在服务端

任何以 NEXT_PUBLIC_ 为前缀的环境变量都会在构建时内联进 JavaScript 包,对每一位访问者都可见。对 Stripe 的可发布密钥或分析 ID 来说这是正确的,对其他任何东西来说就是泄露。请在代码库中搜索 NEXT_PUBLIC_,逐一确认它们就算印在广告牌上也没问题。反过来的错误也会发生:某个没有前缀的变量在客户端组件中被读取,在浏览器里返回 undefined,于是有人把它改名成 NEXT_PUBLIC_ 来消除报错。如果某个值在浏览器里需要用到,请先问问浏览器究竟该不该拥有它。

// lib/dal.ts
import 'server-only';
import { cache } from 'react';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

export const getCurrentUser = cache(async () => {
  const session = await auth();
  if (!session?.user) return null;
  // 只返回界面需要的字段,绝不返回整行
  return db.user.findUnique({
    where: { id: session.user.id },
    select: { id: true, name: true, plan: true },
  });
});

server-only 包会在客户端组件导入该模块时让构建失败,从而防止数据库客户端和读取密钥的辅助函数最终进入浏览器。还要留意服务端组件作为 props 传给客户端组件的东西:跨越这条边界传递的一切都会被序列化进页面,因此传一整行用户记录,就等于把它的密码哈希和内部标志发到了浏览器。

5. CSRF:搞清楚框架覆盖了什么

服务端操作只接受 POST,而且 Next.js 会在运行它们之前比对 Origin 请求头与主机。如果你部署在代理之后或使用多个域名,请有意识地配置 serverActions.allowedOrigins,而不是一路放宽直到报错消失。

路由处理器没有这种保护。如果一个 POST、PUT 或 DELETE 处理器靠 Cookie 做认证,另一个站点上的表单依然可以向它提交。仅检查内容类型本身并不足够:HTML 表单无需预检就能发送 urlencoded、multipart 和 text/plain 的请求体,而一个宽松解析请求体的处理器会照单全收。请把会话 Cookie 设置为 SameSite=Lax 或 Strict,绝不在 GET 上改变状态,并对使用 Cookie 认证的变更操作校验 Origin 请求头。只接受 Authorization 请求头中 bearer 令牌的处理器不受经典 CSRF 影响。

6. 安全响应头与 CSP

Next.js 默认只发送很少的安全响应头。请通过 next.config 中的 headers() 函数添加它们;当你需要为严格的 Content-Security-Policy 提供每请求一个的 nonce 时,则在中间件中添加。

  • Content-Security-Policy,最好基于 nonce,并带 object-src 'none' 和 base-uri 'self'。
  • Strict-Transport-Security,在所有地方的 HTTPS 都稳固之后使用较长的 max-age。
  • X-Content-Type-Options: nosniff。
  • Referrer-Policy: strict-origin-when-cross-origin。
  • CSP 中的 frame-ancestors,或者 X-Frame-Options,用于防止点击劫持。
  • next.config 中的 poweredByHeader: false,用于去掉 X-Powered-By 响应头。

7. 限流

框架没有内置限流器。登录、注册、密码重置、OTP 校验,以及任何按次产生费用的功能(邮件、短信、AI 请求)都需要按用户和按 IP 的限额。内存计数器在 serverless 或多实例部署上不起作用;请使用 Redis 或你的数据库这类共享存储。请在操作或处理器内部施加限额,那里知道已认证的用户,而不是只在中间件里。请把限额挂在攻击者无法廉价轮换的东西上:对已认证路由是账号,对重置和 OTP 流程则是目标邮箱或手机号,此外再加上 IP。返回 429 并带 Retry-After 响应头,好让正常客户端正确地退避。

8. 按原始请求体校验 webhook

Webhook 签名是针对服务商发送的确切字节计算的。把请求体解析成 JSON 再重新序列化会改变这些字节并破坏校验,这会诱使人们干脆跳过它。在路由处理器中,请用 req.text() 读取请求体,并在做任何其他事之前完成校验。

// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get('stripe-signature');
  if (!signature) return new Response('Missing signature', { status: 400 });

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch {
    return new Response('Invalid signature', { status: 400 });
  }

  // 处理逻辑必须幂等:服务商会重试,可能投递两次
  await handleStripeEvent(event);
  return new Response('ok');
}

请用唯一约束存储已处理的事件 ID,这样一次重试或重放的投递就不会把订阅授予两次;同时要处理乱序到达的事件,因为服务商并不保证投递顺序。

9. 不要全局缓存按用户的数据

App Router 的缓存非常激进,而且默认行为在不同版本之间有变化。一个被 unstable_cache 或 'use cache' 包裹、返回当前用户数据却没有把用户 ID 放进缓存键的函数,会把一个用户的数据送给下一个用户。本应动态渲染却被静态渲染的页面,以及被 CDN 缓存的 API 响应,同理。请直接测试:在两个浏览器里以两个不同用户登录并加载同样的页面。任何把第一个用户的数据显示给第二个用户的情况都是缓存漏洞,而且通常很严重。

  • 把用户或租户 ID 显式传入被缓存的函数,让它成为缓存键的一部分。
  • 不要在被缓存的函数内部读取 Cookie 或请求头;请在外部读取并把值传进去。
  • 对包含按用户数据的响应发送 Cache-Control: private, no-store。
  • 检查构建输出:你期望是动态的路由不应被列为静态。

执行这份清单

请按路由而不是按文件来过一遍:对每个操作和处理器,看谁能调用它、它信任什么输入、它缓存了什么、它返回什么。CodeAuditAgent 可以对公开的 GitHub 仓库或粘贴的代码片段做第一遍检查,把每个问题连同严重程度、CWE、原文引用的代码行和建议补丁一并报告;而设计层面的问题,比如哪些路由究竟该不该公开,仍然需要你团队的判断。