Skip to content

当然!我们来探讨一个在 Web 安全领域非常经典,但又常常被误解的攻击方式及其防御手段:CSRF (跨站请求伪造)。这就像是为你的应用配备一位能识别“伪造签名”的笔迹鉴定专家,防止他人冒用你的名义去办理业务。

防范“冒名顶替”:NestJS CSRF 防护完全指南

想象你是一家银行的尊贵客户,你已经成功登录了网上银行 www.my-bank.com。你的浏览器里现在保存着一个有效的“身份手环”(Session Cookie)。

现在,你正在浏览一个看似无害的论坛网站 some-forum.com。这个论坛的某个帖子里,隐藏着一张你看不见的图片,它的代码是这样的:

html
<!-- 一个隐藏的、恶意的图片标签 -->
<img
  src="http://api.my-bank.com/transfer?to=hacker&amount=10000"
  width="1"
  height="1"
/>

发生了什么?

  1. 当你的浏览器试图加载这张“图片”时,它会向 api.my-bank.com/transfer... 这个地址发起一个 GET 请求。
  2. 根据浏览器的规则,在访问 api.my-bank.com 时,它会自动地、无条件地带上你之前登录时获取的那个银行的 Cookie(身份手环)。
  3. 银行的服务器(你的 NestJS 应用)收到这个请求后,一看:“哦,带着有效的手环,是我们的合法客户!” 于是,它就执行了转账操作。
  4. 你的 10000 元钱,就在你毫不知情的情况下,被转给了黑客。

这个过程,就是一次典型的 CSRF (Cross-Site Request Forgery, 跨站请求伪造) 攻击。

CSRF 的核心特征

  • 攻击者不能窃取你的 Cookie,但他可以利用你浏览器里已有的 Cookie。
  • 攻击利用的是你对目标网站的信任(你已经登录了)。
  • 攻击发生在一个第三方网站上 (some-forum.com)。
  • 攻击通常针对的是会改变状态的操作(如转账、下单、修改密码、删除帖子)。

CORS 能防住 CSRF 吗? 不完全能。CORS 主要防止的是恶意网站读取你的 API 响应。但在上面的例子中,黑客不关心转账操作的返回结果,他只想让这个请求被执行。对于这种“简单请求”(如 GET 请求或某些 POST 请求),浏览器可能不会发送 CORS 预检请求,攻击依然可能成功。

我们需要一种方法,来证明这个转账请求确实是你本人在 www.my-bank.com 网站上亲自点击“确认转账”按钮发起的,而不是在别的网站上被动触发的。

双重提交 Cookie 是最常用的一种防御模式,它的原理就像是“口令和印章”:

  1. 当你登录银行网站时,服务器除了给你一个 Session Cookie(“印章”)之外,还会生成一个完全随机的、不可预测的CSRF Token(一个“今日口令”)。
  2. 服务器会将这个“口令”通过两种方式发给你:
    • 一份放在一个新的 Cookie 里(比如 _csrf Cookie)。
    • 另一份直接嵌入到前端页面的 HTML 中(比如放在一个 <meta> 标签或注入到 JavaScript 变量里)。
  3. 当你在银行网站上点击“确认转账”时,前端的 JavaScript 代码会负责:
    • 从页面中读取出那个“口令”。
    • 将这个“口令”放在一个自定义的请求头中(如 X-CSRF-Token)或者请求体中。
  4. 请求到达服务器时,服务器需要进行双重验证
    • 检查 _csrf Cookie 里的“口令”。
    • 检查请求头(或请求体)里的“口令”。
    • 只有当这两个“口令”完全一致时,才认为请求是合法的。

为什么这个方法有效? 因为浏览器的同源策略限制,那个恶意的 some-forum.com 网站的 JavaScript 代码,无法读取my-bank.com 域下的 Cookie,也无法读取my-bank.com 页面中的 HTML 内容。因此,它无法获取到那个“今日口令”,也就无法在它伪造的请求中附带上正确的请求头。

1. 第一步:安装“笔迹鉴定专家” (csurf)

NestJS 可以集成 csurf 这个非常流行的 Express CSRF 防护中间件。

第一步:安装依赖

bash
npm install csurf
# 注意:csurf 依赖于 session 或 cookie-parser,确保你已经安装并配置了其中一个
npm install express-session

第二步:在 main.ts 中全局启用

csurf 必须在 session 中间件之后启用。

src/main.ts

typescript
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() };
  }
}

前端(伪代码):

javascript
// 页面加载时
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 防护纳入你的安全清单。