安全加固
本教程共 47 篇 · 第 23 篇 · 更新于 2026-08-09 · 约 11 分钟阅读
本节目标:掌握 NestJS 中的安全加固手段,包括 CORS 跨域配置、CSRF 防护、Helmet 安全头、接口限流、密码加密与哈希,把 API 的安全防线建起来。
前面几章我们搞定了认证和权限。但安全不只是”验证身份”和”控制权限”——就像家里装了防盗门,你还得关窗户、拉窗帘、装监控。
这一章涵盖 CORS 跨域配置、CSRF 防护、Helmet 安全头、接口限流、加密和哈希。逐个来搞定。
CORS:跨域资源共享
什么是 CORS?
浏览器有个”同源策略”——http://localhost:3000 的页面,默认不能请求 http://localhost:4000 的接口。这是浏览器的安全机制,防止恶意网站偷偷访问你的 API。
但前后端分离的项目,前端和后端几乎必然不在同一个域。这时候就需要后端”主动开口”告诉浏览器:我允许这个域来访问我。
这就是 CORS(Cross-Origin Resource Sharing)。
基本配置
NestJS 开启 CORS 非常简单:
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 一行搞定,允许所有来源
app.enableCors();
await app.listen(3000);
}
bootstrap();
也可以在创建应用时通过选项开启:
const app = await NestFactory.create(AppModule, {
cors: true,
});
精细配置
生产环境中不能允许”所有来源”——那等于没设防。你需要指定具体哪些域可以访问:
app.enableCors({
origin: ['http://localhost:5173', 'https://www.yoursite.com'],
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
credentials: true, // 允许携带 Cookie
maxAge: 86400, // 预检请求缓存 24 小时
});
各字段含义:
| 字段 | 作用 |
|---|---|
origin | 允许的来源列表,可以是字符串、数组或函数 |
methods | 允许的 HTTP 方法 |
allowedHeaders | 允许的请求头 |
credentials | 是否允许携带凭证(Cookie、Authorization 头) |
maxAge | 预检请求(OPTIONS)的缓存时间,单位秒 |
Warning当
credentials: true时,origin不能设为'*'。浏览器会直接报错。必须指定具体的域名。
Tip
origin也可以传一个函数,方便你做动态判断。比如从数据库读取允许的域名列表,或者根据环境变量切换。
app.enableCors({
origin: (origin, callback) => {
const allowedOrigins = ['https://www.yoursite.com', 'http://localhost:5173'];
// 开发环境下允许没有 origin 的请求(比如 Postman)
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('该来源不被允许'));
}
},
});
CSRF 防护
什么是 CSRF?
CSRF(Cross-Site Request Forgery,跨站请求伪造)是一种攻击方式。简单说:
用户登录了 bank.com,Cookie 里存着会话信息。用户又打开了一个恶意网站 evil.com,这个网站里藏了一段代码,自动向 bank.com 发起转账请求。因为浏览器会自动带上 Cookie,所以 bank.com 以为这是用户自己的操作。
这就是”借刀杀人”——利用用户的身份,干坏事。
什么时候需要 CSRF 防护?
如果你用的是 JWT + Authorization 头的方式认证,CSRF 基本不是问题——恶意网站没法自动往请求头里塞 JWT。
但如果你的应用用 Cookie 做认证(session-cookie 方案),那就必须做 CSRF 防护。
使用 csrf-csrf
安装依赖:
npm install csrf-csrf
在 NestJS 中配置:
// main.ts
import { doubleCsrf } from 'csrf-csrf';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// csrf-csrf 需要 cookie-parser 或 session 中间件
app.use(cookieParser());
const { doubleCsrfProtection } = doubleCsrf({
getSecret: () => 'your-csrf-secret',
cookieName: '__csrf',
cookieOptions: {
sameSite: 'strict',
path: '/',
secure: process.env.NODE_ENV === 'production',
},
size: 64,
getTokenFromRequest: (req) => req.headers['x-csrf-token'],
});
// GET 请求通常不需要 CSRF 保护
app.use((req, res, next) => {
if (req.method === 'GET') return next();
return doubleCsrfProtection(req, res, next);
});
await app.listen(3000);
}
前端使用时,先从接口获取 CSRF Token,然后在请求头中带上:
// 获取 CSRF Token
const { token } = await fetch('/api/csrf-token').then(r => r.json());
// 请求时带上 Token
fetch('/api/transfer', {
method: 'POST',
headers: {
'x-csrf-token': token,
},
body: JSON.stringify({ amount: 100 }),
});
Note如果你的 API 纯粹给 SPA 或移动端用,且使用 JWT Bearer Token 认证,CSRF 防护可以不加。但如果用了基于 Cookie 的认证方案,CSRF 防护是必须的。
Helmet:安全响应头
什么是 Helmet?
Helmet 是一个 Express 中间件,它帮你设置一堆 HTTP 安全响应头。这些头告诉浏览器:
- 别加载来路不明的脚本(Content-Security-Policy)
- 别让别人用 iframe 嵌套我的页面(X-Frame-Options)
- 别猜我的内容类型(X-Content-Type-Options)
- 强制使用 HTTPS(Strict-Transport-Security)
打个比方:Helmet 给你的服务器戴上了一顶安全头盔,挡住了一堆常见的 Web 攻击。
安装和使用
npm install helmet
// main.ts
import helmet from 'helmet';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 一定要在其他中间件之前注册
app.use(helmet());
await app.listen(3000);
}
就这么一行,Helmet 会自动帮你设置以下响应头:
| 响应头 | 作用 |
|---|---|
Content-Security-Policy | 控制页面可以加载哪些来源的资源 |
X-Content-Type-Options | 禁止浏览器猜测 Content-Type |
X-Frame-Options | 防止点击劫持,禁止被 iframe 嵌套 |
Strict-Transport-Security | 强制 HTTPS |
X-XSS-Protection | 启用浏览器 XSS 过滤 |
Referrer-Policy | 控制 Referer 头的发送策略 |
自定义配置
默认配置已经能覆盖大部分场景。但有时候你需要微调:
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
styleSrc: ["'self'", "'unsafe-inline'"],
},
},
crossOriginEmbedderPolicy: false, // 如果需要加载外部资源
}),
);
Tip如果你用了 Swagger 文档或者 GraphQL Playground,可能需要调整 CSP 策略,否则页面样式和脚本会被拦截。
接口限流
为什么需要限流?
想象一下:有人写个脚本,每秒向你登录接口发 1000 次请求,尝试不同的密码组合。这就是暴力破解。
限流(Rate Limiting)就是给每个用户设置”请求配额”。比如:每分钟最多 60 次请求。超了?返回 429 状态码(Too Many Requests),你先歇会儿。
安装 @nestjs/throttler
npm install @nestjs/throttler
基本配置
// app.module.ts
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
@Module({
imports: [
ThrottlerModule.forRoot({
throttlers: [
{
ttl: 60000, // 时间窗口:60 秒
limit: 60, // 最多 60 次请求
},
],
}),
],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
})
export class AppModule {}
这段配置的意思是:每个 IP,每 60 秒内最多 60 次请求。超过就返回 429。
多级限流
实际项目中,不同接口可能需要不同的限流策略:
ThrottlerModule.forRoot({
throttlers: [
{
name: 'short',
ttl: 1000, // 1 秒
limit: 3, // 最多 3 次
},
{
name: 'medium',
ttl: 10000, // 10 秒
limit: 20, // 最多 20 次
},
{
name: 'long',
ttl: 60000, // 60 秒
limit: 100, // 最多 100 次
},
],
}),
在控制器中调整限流
有些接口需要更严格的限制,比如登录接口:
import { Throttle, SkipThrottle } from '@nestjs/throttler';
@Controller('auth')
export class AuthController {
// 登录接口:每分钟最多 5 次
@Post('login')
@Throttle({ default: { limit: 5, ttl: 60000 } })
login(@Body() loginDto: LoginDto) {
return this.authService.login(loginDto);
}
// 注册接口:每分钟最多 3 次
@Post('register')
@Throttle({ default: { limit: 3, ttl: 60000 } })
register(@Body() registerDto: RegisterDto) {
return this.authService.register(registerDto);
}
}
有些接口则完全不需要限流,比如健康检查:
@Controller('health')
@SkipThrottle() // 整个控制器跳过限流
export class HealthController {
@Get()
check() {
return { status: 'ok' };
}
}
代理服务器下的限流
如果你的应用跑在 Nginx 后面,限流拿到的 IP 全是 Nginx 的地址。需要配置信任代理:
// main.ts
import { NestExpressApplication } from '@nestjs/platform-express';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// 信任代理,获取真实 IP
app.set('trust proxy', 1);
await app.listen(3000);
}
Tip分布式部署时,内存里的限流计数不共享。每个实例各算各的。生产环境建议用 Redis 做统一存储——
@nestjs/throttler支持自定义storage,社区有throttler-storage-redis包可以直接用。
加密与哈希
加密 vs 哈希
这两个概念经常被搞混,其实差别很大:
| 加密(Encryption) | 哈希(Hashing) | |
|---|---|---|
| 方向 | 双向——可以解密 | 单向——不可逆 |
| 用途 | 保护传输中的数据 | 验证数据(如密码) |
| 密钥 | 需要密钥 | 不需要 |
| 例子 | AES、RSA | bcrypt、argon2 |
打个比方:加密像保险箱,放进去还能拿出来。哈希像碎纸机,碎了就拼不回去。
密码哈希:bcrypt
存储密码的黄金法则是:永远不要存明文密码。存哈希值。
安装 bcrypt:
npm install bcrypt
npm install -D @types/bcrypt
注册时对密码做哈希:
// auth.service.ts
import * as bcrypt from 'bcrypt';
async function hashPassword(password: string): Promise<string> {
const saltRounds = 10;
return bcrypt.hash(password, saltRounds);
}
async function register(email: string, password: string) {
const hashedPassword = await hashPassword(password);
const user = await this.userService.create({
email,
password: hashedPassword, // 存哈希值
});
return user;
}
登录时验证密码:
async function validatePassword(
plainPassword: string,
hashedPassword: string,
): Promise<boolean> {
return bcrypt.compare(plainPassword, hashedPassword);
}
async function login(email: string, password: string) {
const user = await this.userService.findByEmail(email);
if (!user) {
throw new UnauthorizedException('邮箱或密码错误');
}
const isMatch = await validatePassword(password, user.password);
if (!isMatch) {
throw new UnauthorizedException('邮箱或密码错误');
}
// 登录成功,签发 JWT...
}
Note
saltRounds越大越安全,但计算越慢。10 是一个不错的平衡点。太低不安全,太高影响性能。
Tip错误提示用”邮箱或密码错误”,不要说”密码错误”或”邮箱不存在”。否则攻击者可以借此枚举你的注册用户。
数据加密:Node.js crypto 模块
有些场景需要加密(不是哈希)——比如存储用户的 API Key,之后还需要读取出来用。这时候用加密。
Node.js 自带 crypto 模块,不需要额外安装:
import { createCipheriv, createDecipheriv, randomBytes, scrypt } from 'node:crypto';
import { promisify } from 'node:util';
// 加密
async function encrypt(text: string, password: string): Promise<string> {
const iv = randomBytes(16);
const salt = randomBytes(16);
const key = (await promisify(scrypt)(password, salt, 32)) as Buffer;
const cipher = createCipheriv('aes-256-ctr', key, iv);
const encrypted = Buffer.concat([
cipher.update(text),
cipher.final(),
]);
// 把 iv、salt 和密文拼在一起返回
return `${iv.toString('hex')}:${salt.toString('hex')}:${encrypted.toString('hex')}`;
}
// 解密
async function decrypt(encryptedText: string, password: string): Promise<string> {
const [ivHex, saltHex, dataHex] = encryptedText.split(':');
const iv = Buffer.from(ivHex, 'hex');
const salt = Buffer.from(saltHex, 'hex');
const encrypted = Buffer.from(dataHex, 'hex');
const key = (await promisify(scrypt)(password, salt, 32)) as Buffer;
const decipher = createDecipheriv('aes-256-ctr', key, iv);
const decrypted = Buffer.concat([
decipher.update(encrypted),
decipher.final(),
]);
return decrypted.toString();
}
Warning加密密钥(上面的
password参数)一定要通过环境变量管理,绝对不能硬编码在代码里。
安全加固清单
整理一份安全检查清单,上线前对着过一遍:
| 检查项 | 怎么做 | 优先级 |
|---|---|---|
| CORS 配置 | 只允许已知域名 | 高 |
| Helmet 安全头 | app.use(helmet()) | 高 |
| 接口限流 | @nestjs/throttler | 高 |
| 密码存储 | bcrypt 哈希,不存明文 | 高 |
| 敏感数据加密 | 使用 crypto 模块 | 中 |
| CSRF 防护 | Cookie 认证时必须 | 中 |
| 环境变量 | 密钥、密码不硬编码 | 高 |
| HTTPS | 生产环境必须启用 | 高 |
| 输入验证 | 第 25 章详细讲 | 高 |
| SQL 注入 | 使用 ORM 或参数化查询 | 高 |
Tip安全是一个持续的过程,不是一次性的配置。定期更新依赖、审查代码、做渗透测试,都是必要的。
踩坑经验
1. Helmet 必须在其他中间件之前注册
Helmet 本质是一组中间件。Express 的中间件是按注册顺序执行的。如果 Helmet 放在路由之后,它就不会保护那些路由。
// 正确
app.use(helmet());
app.use(cors());
// ... 其他中间件
// ... 路由
// 错误:Helmet 放太后面了
app.use(cors());
app.get('/api/data', handler);
app.use(helmet()); // 对上面的路由无效
2. CORS 的 credentials 和 origin 不能同时用通配符
origin: '*' + credentials: true 会被浏览器拒绝。要么指定具体域名,要么不传 credentials。
3. 限流的 ttl 单位是毫秒
@nestjs/throttler v5+ 的 ttl 单位是毫秒,不是秒。ttl: 60000 才是 60 秒。可以用包提供的 seconds()、minutes() 辅助函数让代码更直观:
import { seconds } from '@nestjs/throttler';
ThrottlerModule.forRoot({
throttlers: [{
ttl: seconds(60), // 比写 60000 清楚多了
limit: 60,
}],
}),
4. bcrypt 的 saltRounds 不是越大越好
saltRounds = 10 时,哈希一次大约 100ms。saltRounds = 15 时可能需要 3 秒。注册接口会变慢。10 到 12 是推荐范围。
小结
关键知识点回顾:
- CORS 配置要注意 credentials 和 origin 不能同时用通配符
- CSRF 防护用
csurf中间件,配合双重 Cookie 或 Token - Helmet 设置安全响应头,必须在其他中间件之前注册
@nestjs/throttler做接口限流,支持多维度限流策略- 密码存储用 bcrypt,saltRounds 推荐 10-12
下一章我们聊聊配置管理,让应用在不同环境下灵活切换。