Skip to content

安全加固

安全体系总览

text
┌─────────────── 请求进入 ───────────────┐
│                                        │
│  1. 安全响应头(security.ts)           │  ← 所有请求
│  2. 速率限制(rate-limit.ts)          │  ← API 请求
│  3. 输入校验(Zod)                    │  ← 请求体
│  4. 认证鉴权(auth.ts)                │  ← 需认证路由
│  5. 业务逻辑处理                       │  ← SQL 注入防护(ORM)
│  6. 响应输出                           │  ← XSS 防护(模板转义)
│                                        │
└────────────────────────────────────────┘

每一层解决一类安全问题,层层防御,单点突破不会导致整体沦陷。

输入校验(Zod)

所有服务端 API 必须使用 Zod 校验输入:

ts
import { z } from 'zod'

// 通用校验规则
export const phoneSchema = z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确')
export const passwordSchema = z.string().min(6).max(20, '密码长度6-20位')
export const emailSchema = z.string().email('邮箱格式不正确')

// 使用
const schema = z.object({
  phone: phoneSchema,
  password: passwordSchema,
})

export default defineEventHandler(async (event) => {
  const data = await readValidatedBody(event, schema.parse)
  // data 已通过校验
})

为什么必须服务端校验?

场景只前端校验前后端都校验
正常用户✅ 拦截无效输入✅ 拦截无效输入
用 Postman 绕过前端❌ 无防护✅ 服务端拦截
修改 HTML 绕过前端验证❌ 无防护✅ 服务端拦截
恶意脚本直接调 API❌ 无防护✅ 服务端拦截

结论:前端校验是用户体验优化,服务端校验是安全保障。两者都不可少。

readValidatedBody vs readBody + 手动校验

ts
// ✅ 推荐:readValidatedBody,校验失败自动返回 400
const data = await readValidatedBody(event, schema.parse)

// ❌ 不推荐:手动校验,容易遗漏
const body = await readBody(event)
if (!body.phone) throw createError(...)

readValidatedBody 的优势:自动捕获 ZodError 并格式化为标准错误响应,不需要手动处理每个字段。

速率限制

内存版(开发/小规模)

ts
// server/middleware/rate-limit.ts
const requestCounts = new Map<string, { count: number; resetTime: number }>()

export default defineEventHandler((event) => {
  const url = getRequestURL(event)

  // 只对 API 路由限流
  if (!url.pathname.startsWith('/api/')) return

  const ip = getRequestHeader(event, 'x-forwarded-for') || 'unknown'
  const key = `${ip}:${url.pathname}`
  const now = Date.now()

  // 不同路由不同限制
  const limits: Record<string, { windowMs: number; maxRequests: number }> = {
    '/api/auth/sms/send': { windowMs: 60_000, maxRequests: 1 },       // 1次/分钟
    '/api/auth/login': { windowMs: 15 * 60_000, maxRequests: 10 },     // 10次/15分钟
    '/api/auth/register': { windowMs: 60 * 60_000, maxRequests: 5 },  // 5次/小时
  }

  const limit = limits[url.pathname] || { windowMs: 60_000, maxRequests: 100 }

  const record = requestCounts.get(key)
  if (!record || now > record.resetTime) {
    requestCounts.set(key, { count: 1, resetTime: now + limit.windowMs })
    return
  }

  record.count++
  if (record.count > limit.maxRequests) {
    throw createError({ statusCode: 429, statusMessage: 'Too Many Requests', message: '请求太频繁,请稍后再试' })
  }
})

Redis 版(生产环境)

内存版有两个问题:(1) 服务器重启后计数丢失;(2) 多实例部署时各实例独立计数,无法共享限流状态。生产环境应使用 Redis:

ts
// server/middleware/rate-limit.ts(Redis 版)
export default defineEventHandler(async (event) => {
  const url = getRequestURL(event)
  if (!url.pathname.startsWith('/api/')) return

  const ip = getRequestHeader(event, 'x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
  const key = `rate-limit:${ip}:${url.pathname}`

  const limits: Record<string, { windowMs: number; maxRequests: number }> = {
    '/api/auth/sms/send': { windowMs: 60_000, maxRequests: 1 },
    '/api/auth/login': { windowMs: 15 * 60_000, maxRequests: 10 },
    '/api/auth/register': { windowMs: 60 * 60_000, maxRequests: 5 },
  }

  const limit = limits[url.pathname] || { windowMs: 60_000, maxRequests: 100 }

  // Redis INCR + EXPIRE 实现滑动窗口
  const count = await redis.incr(key)
  if (count === 1) {
    await redis.expire(key, Math.ceil(limit.windowMs / 1000))
  }

  if (count > limit.maxRequests) {
    // 设置 Retry-After header,让客户端知道多久后可以重试
    const ttl = await redis.ttl(key)
    setResponseHeader(event, 'Retry-After', String(ttl))
    throw createError({ statusCode: 429, statusMessage: 'Too Many Requests', message: '请求太频繁,请稍后再试' })
  }
})

内存版 vs Redis 版对比

对比项内存版Redis 版
重启后状态丢失,限流重置持久化,重启不受影响
多实例共享❌ 各自独立计数✅ 共享 Redis,全局统一限流
性能极快(进程内)快(Redis 单次操作 < 1ms)
额外依赖需要 Redis
适用场景开发环境、单实例生产环境、多实例

什么时候必须用 Redis 版?

当你使用 Docker 部署多个应用实例(如 docker-compose scale app=3)时,内存版无法跨实例共享计数,攻击者可以绕过限流。

SQL 注入防护

Drizzle ORM 使用参数化查询,天然防止 SQL 注入:

ts
// ✅ 安全:参数化查询
await db.select().from(users).where(eq(users.phone, phone))

// ❌ 危险:原始 SQL(避免使用)
await sql`SELECT * FROM users WHERE phone = '${phone}'`

为什么 ORM 能防注入?

参数化查询将 SQL 结构和参数分离发送给数据库。数据库先编译 SQL 结构,再填入参数——参数不会被当作 SQL 执行。即使 phone 的值是 '; DROP TABLE users; --,也只是一个字符串参数。

什么时候可能用到原始 SQL?

Drizzle 支持安全地使用原始 SQL:

ts
// ✅ 安全:使用 sql 模板字符串的参数绑定
import { sql } from 'drizzle-orm'
await db.select().from(users).where(sql`phone LIKE ${phone + '%'}`)

sql 模板字符串自动参数化,参数部分仍然安全。只有手动拼接字符串(如 `phone = '${phone}'`)才有注入风险。

XSS 防护

1. 模板自动转义

ts
// ✅ 安全:Vue 模板自动转义 HTML
// <div>{{ userContent }}</div>

// ❌ 危险:v-html 不转义
// <div v-html="userContent" />

Vue 的 {{ }} 插值自动转义 HTML 字符,<script>alert(1)</script> 会显示为文本而非执行。v-html 绕过了这个保护。

2. 必须使用 v-html 时的过滤

ts
import DOMPurify from 'isomorphic-dompurify'

const safeHtml = computed(() => DOMPurify.sanitize(rawHtml.value))

为什么用 isomorphic-dompurify

普通 dompurify 依赖浏览器的 DOM API,在 SSR 环境会报错。isomorphic-dompurify 同时支持浏览器和 Node.js。

3. 安全响应头

ts
// server/middleware/security.ts
export default defineEventHandler((event) => {
  setResponseHeader(event, 'X-Content-Type-Options', 'nosniff')
  setResponseHeader(event, 'X-Frame-Options', 'DENY')
  setResponseHeader(event, 'X-XSS-Protection', '1; mode=block')
  setResponseHeader(event, 'Referrer-Policy', 'strict-origin-when-cross-origin')
  setResponseHeader(event, 'Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'")
})
Header作用不设置的后果
X-Content-Type-Options: nosniff禁止浏览器猜测 MIME 类型攻击者上传 .txt 文件但内容是 JS,浏览器可能执行
X-Frame-Options: DENY禁止 iframe 嵌入点击劫持攻击
Content-Security-Policy限制资源加载来源XSS 注入的脚本可从外部加载恶意代码

CSRF 防护

ts
setCookie(event, 'auth-token', token, {
  httpOnly: true,
  secure: true,
  sameSite: 'lax',  // 或 'strict'
})

sameSite: 'lax' 阻止跨站 POST 请求携带 Cookie,这是最简单的 CSRF 防护。

CSRF Token(更强的防护)

如果需要更严格的 CSRF 防护(如 sameSite 不够的场景),可以添加 CSRF Token:

ts
// server/middleware/csrf.ts
import { randomBytes } from 'crypto'

export default defineEventHandler((event) => {
  const url = getRequestURL(event)
  if (!url.pathname.startsWith('/api/')) return

  // GET 请求只设置 Token,不验证
  if (getRequestMethod(event) === 'GET') {
    // 如果没有 CSRF Token,生成一个
    let csrfToken = getCookie(event, 'csrf-token')
    if (!csrfToken) {
      csrfToken = randomBytes(32).toString('hex')
      setCookie(event, 'csrf-token', csrfToken, {
        httpOnly: false,  // 前端 JS 需要读取
        secure: true,
        sameSite: 'lax',
      })
    }
    return
  }

  // POST/PUT/DELETE 验证 CSRF Token
  const cookieToken = getCookie(event, 'csrf-token')
  const headerToken = getRequestHeader(event, 'x-csrf-token')

  if (!cookieToken || cookieToken !== headerToken) {
    throw createError({ statusCode: 403, statusMessage: 'Forbidden', message: 'CSRF 验证失败' })
  }
})

原理:攻击者的跨站请求无法读取 Cookie(同源策略),所以无法获取 csrf-token Cookie 的值。前端每次 POST 请求时从 Cookie 读取 Token 放入 X-CSRF-Token header。

本项目是否需要 CSRF Token?

由于使用 sameSite: 'lax' Cookie + JWT Bearer Token 双模式,CSRF 攻击已经被有效阻止。CSRF Token 适用于传统 Cookie-only 认证且 sameSite 不能用的场景。

Token 黑名单(登出实现)

JWT 是无状态的,签发后无法主动撤销。登出需要黑名单机制:

ts
// server/utils/token-blacklist.ts
import { redis } from './cache'

// 将 Token 加入黑名单
export const blacklistToken = async (token: string, expiresInSec: number) => {
  await redis.set(`token:blacklist:${token}`, '1', 'EX', expiresInSec)
}

// 检查 Token 是否在黑名单中
export const isTokenBlacklisted = async (token: string): Promise<boolean> => {
  const result = await redis.get(`token:blacklist:${token}`)
  return result !== null
}

在认证中间件中检查黑名单

ts
// server/middleware/auth.ts 中增加黑名单检查
const token = auth.replace('Bearer ', '')

// 检查黑名单
if (await isTokenBlacklisted(token)) {
  throw createError({ statusCode: 401, statusMessage: 'Unauthorized', message: 'Token 已失效' })
}

const payload = verifyToken(token)

登出 API

ts
// server/api/auth/logout.post.ts
import { decode } from 'jsonwebtoken'

export default defineEventHandler(async (event) => {
  const auth = getRequestHeader(event, 'authorization')
  if (auth?.startsWith('Bearer ')) {
    const token = auth.replace('Bearer ', '')
    // 解码获取过期时间,设置黑名单的 TTL 与 Token 剩余有效期一致
    const decoded = decode(token) as { exp?: number }
    const expiresInSec = decoded?.exp
      ? Math.max(decoded.exp - Math.floor(Date.now() / 1000), 0)
      : 7 * 24 * 3600

    await blacklistToken(token, expiresInSec)
  }

  deleteCookie(event, 'auth-token')
  return { message: '已登出' }
})

为什么黑名单要设置 TTL?

JWT 过期后黑名单记录就无意义了。设置 TTL 自动清理,避免 Redis 存储无限增长。

CORS 配置

ts
// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/api/**': { cors: false },  // API 默认不允许跨域
  },
})

仅在需要的路由开放跨域:

ts
routeRules: {
  '/api/public/**': { cors: true },  // 公共 API 允许跨域
}

为什么默认关闭 CORS?

CORS 是浏览器的安全机制,限制跨域请求。默认关闭意味着只有同源页面能调 API。如果开放 CORS 但没有认证,任何网站都可以调用你的 API。

JWT 安全

ts
// 1. 使用强密钥(至少 64 字节随机值)
NUXT_JWT_SECRET=a-very-long-random-secret-key-at-least-32-chars

// 2. 设置合理的过期时间
const token = jwt.sign(payload, secret, { expiresIn: '7d' })

// 3. 使用 httpOnly Cookie
setCookie(event, 'auth-token', token, {
  httpOnly: true,   // JS 无法读取
  secure: true,     // 仅 HTTPS
  sameSite: 'lax',  // 防止 CSRF
})

// 4. 支持 Token 黑名单(用于登出)
// 使用 Redis 存储已废弃的 Token

JWT 密钥强度

密钥示例强度说明
123456❌ 极弱秒级暴力破解
my-secret-key❌ 弱字典攻击可破解
32 位随机字符串⚠️ 中等防暴力破解,但 HS256 要求密钥 ≥ 256 位
64 字节 hex(推荐)✅ 强crypto.randomBytes(64).toString('hex')

文件上传安全

ts
// server/api/upload.post.ts
import { z } from 'zod'
import { randomBytes } from 'crypto'
import path from 'path'

const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
const MAX_SIZE = 5 * 1024 * 1024 // 5MB

export default defineEventHandler(async (event) => {
  const formData = await readMultipartFormData(event)
  const file = formData?.find(f => f.name === 'file')

  if (!file) {
    throw createError({ statusCode: 400, statusMessage: 'Bad Request', message: '请选择文件' })
  }

  // 1. 检查文件大小
  if (file.data.length > MAX_SIZE) {
    throw createError({ statusCode: 400, statusMessage: 'Bad Request', message: '文件大小不能超过 5MB' })
  }

  // 2. 检查 MIME 类型
  if (!ALLOWED_TYPES.includes(file.type || '')) {
    throw createError({ statusCode: 400, statusMessage: 'Bad Request', message: '只支持 JPG/PNG/GIF/WebP 格式' })
  }

  // 3. 生成随机文件名(不使用原始文件名)
  const ext = path.extname(file.filename || '.jpg').toLowerCase()
  const safeName = `${Date.now()}-${randomBytes(8).toString('hex')}${ext}`

  // 4. 限制文件名扩展名
  const ALLOWED_EXTS = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
  if (!ALLOWED_EXTS.includes(ext)) {
    throw createError({ statusCode: 400, statusMessage: 'Bad Request', message: '文件扩展名不允许' })
  }

  // 5. 存储到上传目录(确保不在 public/ 下)
  const filePath = path.join(process.cwd(), 'uploads', safeName)
  await writeFile(filePath, file.data)

  return { url: `/uploads/${safeName}` }
})

上传安全清单

安全措施原因不做的后果
检查文件大小防止大文件耗尽磁盘攻击者上传 GB 级文件导致磁盘满
检查 MIME 类型防止上传可执行文件上传 .php.js 文件可能被执行
随机文件名防止路径遍历和覆盖../../etc/passwd 或覆盖已有文件
检查扩展名白名单双重验证文件类型file.exe.jpg 可能绕过 MIME 检查
上传目录不在 public/防止直接访问上传的文件可能通过 URL 直接执行
上传目录不可执行防止代码执行即使上传了 .php,也不会被服务器执行

为什么不能只检查文件扩展名?

攻击者可以修改扩展名(.php.jpg),但文件内容仍是 PHP 代码。检查 MIME 类型更可靠,但 MIME 也可以伪造。最佳实践:两者都检查,且存储目录不可执行。

HTTPS

生产环境必须使用 HTTPS:

bash
# 使用 Let's Encrypt 免费证书
sudo certbot --nginx -d example.com

为什么必须 HTTPS?

HTTP 下所有数据明文传输,包括 JWT Token 和密码。即使使用 httpOnly Cookie,HTTP 下仍可被中间人截获。

安全检查清单

  • [ ] 所有 API 输入使用 Zod 校验
  • [ ] 敏感路由有速率限制
  • [ ] 使用参数化查询(ORM)
  • [ ] 不使用 v-html,或使用 DOMPurify 过滤
  • [ ] Cookie 设置 httpOnly + secure + sameSite
  • [ ] 安全响应头已设置
  • [ ] HTTPS 已启用
  • [ ] JWT 密钥足够强(≥ 64 字节随机值)
  • [ ] 环境变量不包含在代码中
  • [ ] CORS 仅开放必要路由
  • [ ] 文件上传有大小限制 + MIME 检查 + 随机文件名
  • [ ] Token 黑名单支持登出
  • [ ] 上传目录不在 public/ 且不可执行
  • [ ] 生产环境使用 Redis 限流(多实例场景)

基于 Nuxt 4 官方文档整理编写