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()
// 业务逻辑...
}1.3 Cookie 安全配置
// 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 转义——{userInput} 中的 <script> 会被转义为 <script>,不会执行。
但有两个例外需要特别注意:
// ❌ 危险:dangerouslySetInnerHTML 绕过了 React 的转义
<div dangerouslySetInnerHTML={{ __html: userContent }} />
// ❌ 危险:href 中的 javascript: 协议
<a href={userProvidedUrl}>Link</a> // 如果 url 是 "javascript:alert(1)" → XSS2.3 输入净化
对于必须渲染 HTML 的场景(如富文本编辑器的内容),使用 HTML 净化库:
pnpm add isomorphic-dompurifyimport 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 响应头,告诉浏览器只允许加载来自指定来源的资源。即使攻击者注入了 <script> 标签,如果脚本来源不在白名单中,浏览器也不会执行。
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-src | JavaScript 来源 | 'self' 'nonce-xxx' |
style-src | CSS 来源 | 'self' 'unsafe-inline'(Tailwind 需要 inline style) |
img-src | 图片来源 | 'self' data: blob: https://your-cdn.com |
connect-src | fetch/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 nextConfig5.2 各安全头的作用
| Header | 防护什么 | 说明 |
|---|---|---|
X-Frame-Options: DENY | 点击劫持 | 禁止任何网站用 iframe 嵌入你的页面 |
X-Content-Type-Options: nosniff | MIME 嗅探 | 浏览器不猜测文件类型,防止把文本当 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)
- 安全 Headers:
X-Frame-Options、HSTS、nosniff等,在next.config.ts统一配置 - Server Actions:必须验证输入(Zod)+ 认证检查 + 权限检查(防越权)
- 环境变量:永远不给敏感值加
NEXT_PUBLIC_前缀,启动时用 Zod 校验