Next.js 安全防护体系

认证解决了"你是谁"的问题,安全防护解决的是"如何防御攻击"。Web 应用面临的攻击种类繁多——CSRF 伪造请求、XSS 注入脚本、暴力破解、点击劫持等。Next.js App Router 的架构天然规避了部分风险(Server Components 不暴露 JS),但仍需主动配置多层防御。本章建立完整的安全防护体系。

1. CSRF 防护

1.1 什么是 CSRF

CSRF(Cross-Site Request Forgery,跨站请求伪造):攻击者诱导已登录用户访问恶意页面,该页面自动向你的应用发送请求(携带用户的 Cookie),执行非预期操作。

用户已登录 bank.com(浏览器存有 Cookie)
  ↓
用户访问 evil.com
  ↓
evil.com 页面中有: <img src="https://bank.com/transfer?to=attacker&amount=10000">
  ↓
浏览器自动携带 bank.com 的 Cookie 发送请求
  ↓
转账成功(bank.com 认为是用户自己操作的)

1.2 Next.js 的 CSRF 防护

Server Actions 自带 CSRF 防护:Next.js 的 Server Actions 使用 POST 请求 + 自动生成的 CSRF Token(通过 Origin 头验证),不需要手动处理。

API Routes 需要手动防护

// lib/csrf.ts
import { headers } from 'next/headers'
 
export async function validateOrigin() {
  const headersList = await headers()
  const origin = headersList.get('origin')
  const host = headersList.get('host')
 
  // 检查 Origin 是否匹配当前域名
  if (!origin) {
    throw new Error('Missing Origin header')
  }
 
  const allowedOrigins = [
    `https://${host}`,
    process.env.NEXT_PUBLIC_APP_URL,
  ].filter(Boolean)
 
  if (!allowedOrigins.includes(origin)) {
    throw new Error(`Invalid origin: ${origin}`)
  }
}
// app/api/transfer/route.ts
import { validateOrigin } from '@/lib/csrf'
 
export async function POST(request: Request) {
  // 验证请求来源
  await validateOrigin()
 
  // 业务逻辑...
}
// Auth.js 中配置安全 Cookie
export const { handlers, signIn, signOut, auth } = NextAuth({
  // ...
  cookies: {
    sessionToken: {
      name: '__Secure-next-auth.session-token',
      options: {
        httpOnly: true,     // JS 无法读取(防 XSS 窃取 Cookie)
        sameSite: 'lax',    // 只在同站请求中携带(防 CSRF)
        secure: true,       // 只通过 HTTPS 传输
        path: '/',
      },
    },
  },
})

SameSite 属性详解:

行为CSRF 防护
strict任何跨站请求都不携带 Cookie最强,但从外链进入你的网站也不携带(影响体验)
lax(推荐)GET 跨站请求携带,POST 不携带够用——CSRF 攻击主要是 POST
none总是携带(需配合 secure无防护——只用于嵌入 iframe 等场景

2. XSS 防护

2.1 什么是 XSS

XSS(Cross-Site Scripting,跨站脚本):攻击者在你的页面中注入恶意 JavaScript。分三种:

  • 存储型 XSS:恶意脚本存入数据库(如评论),其他用户访问页面时执行
  • 反射型 XSS:恶意脚本在 URL 参数中,服务端返回页面时嵌入
  • DOM 型 XSS:恶意脚本通过客户端 JS 操作 DOM 注入

2.2 React/Next.js 的天然防护

React 默认对所有渲染内容进行 HTML 转义——&#123;userInput&#125; 中的 &lt;script&gt; 会被转义为 &lt;script&gt;,不会执行。

但有两个例外需要特别注意:

// ❌ 危险:dangerouslySetInnerHTML 绕过了 React 的转义
<div dangerouslySetInnerHTML={{ __html: userContent }} />
 
// ❌ 危险:href 中的 javascript: 协议
<a href={userProvidedUrl}>Link</a>  // 如果 url 是 "javascript:alert(1)" → XSS

2.3 输入净化

对于必须渲染 HTML 的场景(如富文本编辑器的内容),使用 HTML 净化库:

pnpm add isomorphic-dompurify
import DOMPurify from 'isomorphic-dompurify'
 
// 净化用户提供的 HTML
function SafeHTML({ html }: { html: string }) {
  const clean = DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a', 'ul', 'ol', 'li', 'code', 'pre', 'h1', 'h2', 'h3'],
    ALLOWED_ATTR: ['href', 'target', 'rel'],
    ALLOW_DATA_ATTR: false,
  })
 
  return <div dangerouslySetInnerHTML={{ __html: clean }} />
}
// URL 验证 — 防止 javascript: 协议
function SafeLink({ href, children }: { href: string; children: React.ReactNode }) {
  const isValid = href.startsWith('https://') || href.startsWith('http://') || href.startsWith('/')
 
  if (!isValid) {
    return <span>{children}</span>  // 不渲染为链接
  }
 
  return (
    <a href={href} rel="noopener noreferrer" target="_blank">
      {children}
    </a>
  )
}

2.4 Server Component 的安全优势

Server Components 本身就是 XSS 防护的一层——它们的代码不会发送到客户端,攻击者无法通过浏览器 DevTools 看到或篡改服务端逻辑。敏感操作(数据库查询、API 调用)留在 Server Component 中:

// ✅ 安全:数据库查询在服务端,客户端完全看不到
export default async function UserPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
 
  // 这行代码不会出现在 client bundle 中
  const user = await db.query.users.findFirst({
    where: eq(users.id, id),
  })
 
  return <UserProfile user={user} />
}

3. Content Security Policy (CSP)

3.1 什么是 CSP

CSP 是 HTTP 响应头,告诉浏览器只允许加载来自指定来源的资源。即使攻击者注入了 &lt;script&gt; 标签,如果脚本来源不在白名单中,浏览器也不会执行。

CSP 是 XSS 的最后一道防线——即使前面的防护全部失效,CSP 也能阻止恶意脚本执行。

3.2 在 Next.js 中配置 CSP

方式一:Middleware(推荐——支持 nonce)

// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
 
export function middleware(request: NextRequest) {
  // 为每个请求生成唯一的 nonce
  const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
 
  const cspHeader = `
    default-src 'self';
    script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
    style-src 'self' 'unsafe-inline';
    img-src 'self' blob: data: https://cdn.example.com;
    font-src 'self' https://fonts.gstatic.com;
    connect-src 'self' https://api.example.com https://vitals.vercel-insights.com;
    frame-ancestors 'none';
    base-uri 'self';
    form-action 'self';
  `.replace(/\s{2,}/g, ' ').trim()
 
  const response = NextResponse.next()
 
  // 设置 CSP 头
  response.headers.set('Content-Security-Policy', cspHeader)
 
  // 传递 nonce 给 Server Components
  response.headers.set('x-nonce', nonce)
 
  return response
}
// app/layout.tsx — 使用 nonce
import { headers } from 'next/headers'
 
export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const headersList = await headers()
  const nonce = headersList.get('x-nonce') ?? ''
 
  return (
    <html>
      <body>
        {/* Next.js 内联脚本会自动使用 nonce */}
        {children}
      </body>
    </html>
  )
}

方式二:next.config.ts headers

// next.config.ts — 适合不需要 nonce 的简单场景
const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          {
            key: 'Content-Security-Policy',
            value: "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; frame-ancestors 'none';",
          },
        ],
      },
    ]
  },
}

3.3 CSP 指令详解

指令控制什么推荐值
default-src所有资源的默认策略'self'
script-srcJavaScript 来源'self' 'nonce-xxx'
style-srcCSS 来源'self' 'unsafe-inline'(Tailwind 需要 inline style)
img-src图片来源'self' data: blob: https://your-cdn.com
connect-srcfetch/XHR/WebSocket 来源'self' https://your-api.com
frame-ancestors谁可以嵌入你的页面'none'(防点击劫持)
form-action表单提交目标'self'

3.4 调试 CSP

先用 Content-Security-Policy-Report-Only 头——只报告违规但不阻止:

response.headers.set('Content-Security-Policy-Report-Only', cspHeader)
response.headers.set('Report-To', JSON.stringify({
  group: 'csp',
  max_age: 10886400,
  endpoints: [{ url: '/api/csp-report' }],
}))

在浏览器控制台中会看到 CSP 违规警告,帮你调整策略。

4. Rate Limiting

4.1 为什么需要限流

  • 暴力破解:攻击者高速尝试密码
  • API 滥用:脚本大量调用你的 API
  • DDoS:大量请求拖垮服务
  • 爬虫:大量抓取你的内容

4.2 基于 Upstash Redis 的限流

pnpm add @upstash/ratelimit @upstash/redis
// lib/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
 
const redis = Redis.fromEnv()
 
// 通用限流:每分钟 60 次
export const generalLimiter = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(60, '1 m'),
  prefix: 'ratelimit:general',
})
 
// 登录限流:每 15 分钟 5 次(防暴力破解)
export const authLimiter = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(5, '15 m'),
  prefix: 'ratelimit:auth',
})
 
// AI API 限流:每天 100 次(成本控制)
export const aiLimiter = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(100, '1 d'),
  prefix: 'ratelimit:ai',
})

4.3 在 Middleware 中全局限流

// middleware.ts
import { generalLimiter } from '@/lib/rate-limit'
 
export async function middleware(request: NextRequest) {
  // 获取客户端 IP
  const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
    ?? request.headers.get('x-real-ip')
    ?? '127.0.0.1'
 
  // API 路由限流
  if (request.nextUrl.pathname.startsWith('/api')) {
    const { success, limit, remaining, reset } = await generalLimiter.limit(ip)
 
    if (!success) {
      return new NextResponse('Too Many Requests', {
        status: 429,
        headers: {
          'X-RateLimit-Limit': limit.toString(),
          'X-RateLimit-Remaining': remaining.toString(),
          'X-RateLimit-Reset': reset.toString(),
          'Retry-After': Math.ceil((reset - Date.now()) / 1000).toString(),
        },
      })
    }
  }
 
  return NextResponse.next()
}

4.4 路由级精细限流

// app/api/auth/login/route.ts
import { authLimiter } from '@/lib/rate-limit'
 
export async function POST(request: Request) {
  const ip = request.headers.get('x-forwarded-for') ?? '127.0.0.1'
  const body = await request.json()
 
  // 同时限制 IP 和邮箱(防止分布式暴力破解)
  const [ipResult, emailResult] = await Promise.all([
    authLimiter.limit(`ip:${ip}`),
    authLimiter.limit(`email:${body.email}`),
  ])
 
  if (!ipResult.success || !emailResult.success) {
    return Response.json(
      { error: '登录尝试过多,请 15 分钟后重试' },
      { status: 429 },
    )
  }
 
  // 登录逻辑...
}

5. 安全 Headers

5.1 完整安全头配置

// next.config.ts
const securityHeaders = [
  // 防止点击劫持(iframe 嵌入攻击)
  { key: 'X-Frame-Options', value: 'DENY' },
 
  // 防止 MIME 类型嗅探
  { key: 'X-Content-Type-Options', value: 'nosniff' },
 
  // 控制 Referer 信息泄露
  { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
 
  // 强制 HTTPS(告诉浏览器未来 1 年都只用 HTTPS 访问)
  { key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' },
 
  // 控制浏览器特性(摄像头、麦克风、定位等)
  { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()' },
 
  // DNS 预解析控制
  { key: 'X-DNS-Prefetch-Control', value: 'on' },
]
 
const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: securityHeaders,
      },
    ]
  },
}
 
export default nextConfig

5.2 各安全头的作用

Header防护什么说明
X-Frame-Options: DENY点击劫持禁止任何网站用 iframe 嵌入你的页面
X-Content-Type-Options: nosniffMIME 嗅探浏览器不猜测文件类型,防止把文本当 JS 执行
Referrer-Policy信息泄露控制跳转到外部链接时携带的来源信息
Strict-Transport-Security中间人攻击强制 HTTPS,防止 SSL 降级
Permissions-Policy隐私禁用不需要的浏览器 API(摄像头等)

6. Server Actions 安全

6.1 输入验证

Server Actions 是公开的 HTTP 端点——任何人都可以直接调用。必须验证所有输入:

'use server'
 
import { z } from 'zod'
import { auth } from '@/auth'
 
const updateProfileSchema = z.object({
  name: z.string().min(1).max(100),
  bio: z.string().max(500).optional(),
  website: z.string().url().optional().or(z.literal('')),
})
 
export async function updateProfile(formData: FormData) {
  // 1. 认证检查
  const session = await auth()
  if (!session?.user) throw new Error('Unauthorized')
 
  // 2. 输入验证(不信任任何客户端数据)
  const parsed = updateProfileSchema.safeParse({
    name: formData.get('name'),
    bio: formData.get('bio'),
    website: formData.get('website'),
  })
 
  if (!parsed.success) {
    return { error: parsed.error.flatten().fieldErrors }
  }
 
  // 3. 权限检查(只能更新自己的资料)
  await db.update(users)
    .set(parsed.data)
    .where(eq(users.id, session.user.id))
 
  // 4. 刷新缓存
  revalidatePath('/profile')
  return { success: true }
}

6.2 防止越权操作

'use server'
 
export async function deletePost(postId: string) {
  const session = await auth()
  if (!session?.user) throw new Error('Unauthorized')
 
  // ❌ 危险:只检查了帖子是否存在,没检查是否属于当前用户
  // await db.delete(posts).where(eq(posts.id, postId))
 
  // ✅ 安全:同时检查帖子 ID 和所有者
  const result = await db.delete(posts).where(
    and(
      eq(posts.id, postId),
      eq(posts.userId, session.user.id),  // 关键:确保是自己的帖子
    ),
  )
 
  if (result.rowCount === 0) {
    throw new Error('Post not found or unauthorized')
  }
}

7. 环境变量安全

7.1 命名规范

# 服务端专用(不会暴露给浏览器)
DATABASE_URL=postgresql://...
AUTH_SECRET=xxx
OPENAI_API_KEY=sk-xxx
 
# 客户端可见(会打包进 client bundle)
NEXT_PUBLIC_APP_URL=https://example.com
NEXT_PUBLIC_ANALYTICS_ID=G-xxx

规则:只有 NEXT_PUBLIC_ 前缀的环境变量会暴露给客户端。永远不要把 API Key、数据库连接串加 NEXT_PUBLIC_ 前缀。

7.2 运行时校验

// lib/env.ts
import { z } from 'zod'
 
const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  AUTH_SECRET: z.string().min(32),
  OPENAI_API_KEY: z.string().startsWith('sk-'),
  NEXT_PUBLIC_APP_URL: z.string().url(),
})
 
// 应用启动时校验——缺少环境变量立即报错,不要等到运行时才发现
export const env = envSchema.parse(process.env)

本章小结

  • CSRF:Server Actions 自带防护,API Routes 需手动验证 Origin,Cookie 设置 SameSite: lax + HttpOnly
  • XSS:React 默认转义,dangerouslySetInnerHTML 必须先净化(DOMPurify),验证 URL 防止 javascript: 协议
  • CSP:Middleware 中设置 Content-Security-Policy,使用 nonce 配合 strict-dynamic,先用 Report-Only 调试
  • Rate Limiting:Upstash Redis 实现多层限流——全局(Middleware)+ 路由级(登录、AI API)
  • 安全 HeadersX-Frame-OptionsHSTSnosniff 等,在 next.config.ts 统一配置
  • Server Actions:必须验证输入(Zod)+ 认证检查 + 权限检查(防越权)
  • 环境变量:永远不给敏感值加 NEXT_PUBLIC_ 前缀,启动时用 Zod 校验