当然!我们来探讨一个在 Web 安全领域非常经典,但又常常被误解的攻击方式及其防御手段:CSRF (跨站请求伪造)。这就像是为你的应用配备一位能识别“伪造签名”的笔迹鉴定专家,防止他人冒用你的名义去办理业务。
防范“冒名顶替”:NestJS CSRF 防护完全指南
想象你是一家银行的尊贵客户,你已经成功登录了网上银行 www.my-bank.com
。你的浏览器里现在保存着一个有效的“身份手环”(Session Cookie)。
现在,你正在浏览一个看似无害的论坛网站 some-forum.com
。这个论坛的某个帖子里,隐藏着一张你看不见的图片,它的代码是这样的:
<!-- 一个隐藏的、恶意的图片标签 -->
<img
src="http://api.my-bank.com/transfer?to=hacker&amount=10000"
width="1"
height="1"
/>
发生了什么?
- 当你的浏览器试图加载这张“图片”时,它会向
api.my-bank.com/transfer...
这个地址发起一个GET
请求。 - 根据浏览器的规则,在访问
api.my-bank.com
时,它会自动地、无条件地带上你之前登录时获取的那个银行的 Cookie(身份手环)。 - 银行的服务器(你的 NestJS 应用)收到这个请求后,一看:“哦,带着有效的手环,是我们的合法客户!” 于是,它就执行了转账操作。
- 你的 10000 元钱,就在你毫不知情的情况下,被转给了黑客。
这个过程,就是一次典型的 CSRF (Cross-Site Request Forgery, 跨站请求伪造) 攻击。
CSRF 的核心特征:
- 攻击者不能窃取你的 Cookie,但他可以利用你浏览器里已有的 Cookie。
- 攻击利用的是你对目标网站的信任(你已经登录了)。
- 攻击发生在一个第三方网站上 (
some-forum.com
)。 - 攻击通常针对的是会改变状态的操作(如转账、下单、修改密码、删除帖子)。
CORS 能防住 CSRF 吗? 不完全能。CORS 主要防止的是恶意网站读取你的 API 响应。但在上面的例子中,黑客不关心转账操作的返回结果,他只想让这个请求被执行。对于这种“简单请求”(如 GET
请求或某些 POST
请求),浏览器可能不会发送 CORS 预检请求,攻击依然可能成功。
CSRF 的“解药”:双重提交 Cookie (Double Submit Cookie) 模式
我们需要一种方法,来证明这个转账请求确实是你本人在 www.my-bank.com
网站上亲自点击“确认转账”按钮发起的,而不是在别的网站上被动触发的。
双重提交 Cookie 是最常用的一种防御模式,它的原理就像是“口令和印章”:
- 当你登录银行网站时,服务器除了给你一个 Session Cookie(“印章”)之外,还会生成一个完全随机的、不可预测的CSRF Token(一个“今日口令”)。
- 服务器会将这个“口令”通过两种方式发给你:
- 一份放在一个新的 Cookie 里(比如
_csrf
Cookie)。 - 另一份直接嵌入到前端页面的 HTML 中(比如放在一个
<meta>
标签或注入到 JavaScript 变量里)。
- 一份放在一个新的 Cookie 里(比如
- 当你在银行网站上点击“确认转账”时,前端的 JavaScript 代码会负责:
- 从页面中读取出那个“口令”。
- 将这个“口令”放在一个自定义的请求头中(如
X-CSRF-Token
)或者请求体中。
- 请求到达服务器时,服务器需要进行双重验证:
- 检查
_csrf
Cookie 里的“口令”。 - 检查请求头(或请求体)里的“口令”。
- 只有当这两个“口令”完全一致时,才认为请求是合法的。
- 检查
为什么这个方法有效? 因为浏览器的同源策略限制,那个恶意的 some-forum.com
网站的 JavaScript 代码,无法读取到 my-bank.com
域下的 Cookie,也无法读取到 my-bank.com
页面中的 HTML 内容。因此,它无法获取到那个“今日口令”,也就无法在它伪造的请求中附带上正确的请求头。
1. 第一步:安装“笔迹鉴定专家” (csurf
)
NestJS 可以集成 csurf
这个非常流行的 Express CSRF 防护中间件。
第一步:安装依赖
npm install csurf
# 注意:csurf 依赖于 session 或 cookie-parser,确保你已经安装并配置了其中一个
npm install express-session
第二步:在 main.ts
中全局启用
csurf
必须在 session
中间件之后启用。
src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as session from 'express-session';
import * as csurf from 'csurf';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 1. 先启用 session
app.use(
session({
secret: 'your-secret-key-for-session',
resave: false,
saveUninitialized: false,
}),
);
// 2. 启用 csurf 中间件
app.use(csurf());
await app.listen(3000);
}
bootstrap();```
### 2. 第二步:将“口令”传递给前端
启用 `csurf` 后,它会自动在 `req.session` 中生成 CSRF Token。我们需要一个接口,让前端页面在加载时可以获取到这个 Token。
**`src/auth/auth.controller.ts`**
```typescript
import { Controller, Get, Req } from '@nestjs/common';
import { Request } from 'express';
@Controller('auth')
export class AuthController {
@Get('csrf-token')
getCsrfToken(@Req() req: Request) {
// csurf 会在 req 对象上附加一个 csrfToken() 方法
return { csrfToken: req.csrfToken() };
}
}
前端(伪代码):
// 页面加载时
async function fetchCsrfToken() {
const response = await fetch('/auth/csrf-token');
const { csrfToken } = await response.json();
// 将 token 存储起来,比如存在一个全局变量或 meta 标签中
window.csrfToken = csrfToken;
}
// 发送 POST 请求时
async function submitForm() {
const response = await fetch('/some-protected-endpoint', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// 将 token 放入一个自定义请求头中
'X-CSRF-Token': window.csrfToken,
},
body: JSON.stringify({ /* ... */ }),
});
}```
### 3. 第三步:验证“口令”
这一步是**全自动的**!
`csurf` 中间件会自动检查所有“写入”操作的请求(`POST`, `PUT`, `PATCH`, `DELETE`)。它会查找请求头、请求体或查询参数中的 `_csrf` 字段(或者你配置的其他字段),并将其与 Session Cookie 中存储的 Token 进行比较。
如果验证失败,`csurf` 会自动抛出一个 `ForbiddenException`,你的 Controller 方法根本不会被执行。
### 4. 现代 Web 架构下的 CSRF 思考
`csurf` 这种基于 Session 和 Cookie 的传统方案,非常适合**传统的 MVC 应用**(后端渲染 HTML)。但在现代**前后端分离**、特别是使用 **JWT 进行无状态认证**的架构中,情况变得有些不同。
**痛点与演进**:
1. **无状态下的 CSRF**:如果你的认证完全基于 JWT,存储在 `localStorage` 中,并且不使用 Cookie,那么传统的 CSRF 攻击就失效了,因为浏览器不会自动发送 `Authorization` Header。**但是**,一旦你为了某种便利(比如 SSR 时的身份验证)而将 JWT 存入了 Cookie,那么 CSRF 的风险就又回来了。
2. **API 的复杂性**:在前后端分离的应用中,让前端专门去调用一个 `/csrf-token` 接口,增加了前端的开发复杂性。
**企业级方案 Demo:基于 JWT 的无状态 CSRF 防护**
这是一种更现代的、与 JWT 结合的“双重提交”模式,它**不需要 Session**。
* **登录时**:
* 服务器签发一个标准的 **Access Token (JWT)**。
* 服务器**同时**生成一个随机的、不可预测的 **CSRF Secret**。
* 将这个 `csrfSecret` 作为**一个私有的声明 (private claim)**,包含在 **Access Token 的 payload** 里面一起进行签名。
* **只将 Access Token** 返回给客户端。
* **刷新或首次加载页面时**:
* 前端需要一个接口(比如 `/auth/session-info`),它需要 `Authorization` Header。
* 这个接口在验证 Access Token 后,除了返回用户信息外,还会从 Access Token 的 payload 中**提取出那个 `csrfSecret`**,并将其返回给前端。前端将其存储在内存中。
* **发送写入请求时**:
* 前端将内存中的 `csrfSecret` 放入 `X-CSRF-Token` 请求头中。
* **服务器验证时**:
* 创建一个**自定义的 `CsrfGuard`**。
* 这个守卫会:
1. 从 `Authorization` Header 中解码出 Access Token,并从中提取出 `csrfSecret`。
2. 从 `X-CSRF-Token` 请求头中获取前端提交的 `csrfSecret`。
3. **比较这两个值**。如果一致,则验证通过。
**`CsrfGuard` 伪代码**:
```typescript
@Injectable()
export class JwtCsrfGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
// 假设 AuthGuard 已经执行,并将解码后的 JWT payload 放在 req.user 上
const userPayload = request.user;
const csrfFromToken = userPayload.csrfSecret;
const csrfFromHeader = request.headers['x-csrf-token'];
if (!csrfFromToken || !csrfFromHeader || csrfFromToken !== csrfFromHeader) {
throw new ForbiddenException('Invalid CSRF Token');
}
return true;
}
}
这种方式将 CSRF 的防护与 JWT 的生命周期绑定在了一起,实现了无状态的 CSRF 防护,非常适合现代的前后端分离架构。
总结
CSRF 是一种利用用户身份来伪造请求的常见攻击,防护它的核心是确保请求的来源是可信的。
架构模式 | 防护方案 | 核心原理 |
---|---|---|
传统 MVC / 基于 Session 的应用 | 使用 csurf 中间件 | 双重提交 Cookie。服务器通过 Session Cookie 和页面内容下发同一个 Token,并在请求时进行比对。 |
现代前后端分离 / 基于 JWT 的应用 | 自定义的、与 JWT 绑定的双重提交方案 | 将 CSRF 密钥作为 JWT 的一个私有声明,在请求时,将 JWT 中的密钥与 Header 中的密钥进行比对,实现无状态的 CSRF 防护。 |
记住,安全不是单一功能,而是一个体系。CORS, CSRF, Helmet, 哈希加密... 它们共同构成了你应用坚固的“防御工事”。在构建任何会改变用户数据的应用时,都必须将 CSRF 防护纳入你的安全清单。