安全加固
安全体系总览
┌─────────────── 请求进入 ───────────────┐
│ │
│ 1. 安全响应头(security.ts) │ ← 所有请求
│ 2. 速率限制(rate-limit.ts) │ ← API 请求
│ 3. 输入校验(Zod) │ ← 请求体
│ 4. 认证鉴权(auth.ts) │ ← 需认证路由
│ 5. 业务逻辑处理 │ ← SQL 注入防护(ORM)
│ 6. 响应输出 │ ← XSS 防护(模板转义)
│ │
└────────────────────────────────────────┘每一层解决一类安全问题,层层防御,单点突破不会导致整体沦陷。
输入校验(Zod)
所有服务端 API 必须使用 Zod 校验输入:
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 + 手动校验
// ✅ 推荐:readValidatedBody,校验失败自动返回 400
const data = await readValidatedBody(event, schema.parse)
// ❌ 不推荐:手动校验,容易遗漏
const body = await readBody(event)
if (!body.phone) throw createError(...)readValidatedBody 的优势:自动捕获 ZodError 并格式化为标准错误响应,不需要手动处理每个字段。
速率限制
内存版(开发/小规模)
// 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:
// 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 注入:
// ✅ 安全:参数化查询
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:
// ✅ 安全:使用 sql 模板字符串的参数绑定
import { sql } from 'drizzle-orm'
await db.select().from(users).where(sql`phone LIKE ${phone + '%'}`)sql 模板字符串自动参数化,参数部分仍然安全。只有手动拼接字符串(如 `phone = '${phone}'`)才有注入风险。
XSS 防护
1. 模板自动转义
// ✅ 安全:Vue 模板自动转义 HTML
// <div>{{ userContent }}</div>
// ❌ 危险:v-html 不转义
// <div v-html="userContent" />Vue 的 {{ }} 插值自动转义 HTML 字符,<script>alert(1)</script> 会显示为文本而非执行。v-html 绕过了这个保护。
2. 必须使用 v-html 时的过滤
import DOMPurify from 'isomorphic-dompurify'
const safeHtml = computed(() => DOMPurify.sanitize(rawHtml.value))为什么用 isomorphic-dompurify?
普通 dompurify 依赖浏览器的 DOM API,在 SSR 环境会报错。isomorphic-dompurify 同时支持浏览器和 Node.js。
3. 安全响应头
// 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 防护
SameSite Cookie(已实现)
setCookie(event, 'auth-token', token, {
httpOnly: true,
secure: true,
sameSite: 'lax', // 或 'strict'
})sameSite: 'lax' 阻止跨站 POST 请求携带 Cookie,这是最简单的 CSRF 防护。
CSRF Token(更强的防护)
如果需要更严格的 CSRF 防护(如 sameSite 不够的场景),可以添加 CSRF Token:
// 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 是无状态的,签发后无法主动撤销。登出需要黑名单机制:
// 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
}在认证中间件中检查黑名单
// 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
// 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 配置
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
'/api/**': { cors: false }, // API 默认不允许跨域
},
})仅在需要的路由开放跨域:
routeRules: {
'/api/public/**': { cors: true }, // 公共 API 允许跨域
}为什么默认关闭 CORS?
CORS 是浏览器的安全机制,限制跨域请求。默认关闭意味着只有同源页面能调 API。如果开放 CORS 但没有认证,任何网站都可以调用你的 API。
JWT 安全
// 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 存储已废弃的 TokenJWT 密钥强度
| 密钥示例 | 强度 | 说明 |
|---|---|---|
123456 | ❌ 极弱 | 秒级暴力破解 |
my-secret-key | ❌ 弱 | 字典攻击可破解 |
| 32 位随机字符串 | ⚠️ 中等 | 防暴力破解,但 HS256 要求密钥 ≥ 256 位 |
| 64 字节 hex(推荐) | ✅ 强 | crypto.randomBytes(64).toString('hex') |
文件上传安全
// 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:
# 使用 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 限流(多实例场景)