【NestJS应用从0到1】10.安全②-防御CSRF

649 阅读5分钟

这里不对CSRF攻击方式及原理描述不做详细介绍。各种技术博客已经讲的很详细了,如果不了解的话,可以自行搜索查看。

默认你对guard,interceptor等已经了解

在这之前先了解CSRF的基本知识

CSRF,全称是Cross-Site Request Forgery(跨站请求伪造),也是一种常见的网络攻击方式。它利用用户已经验证的身份,在用户不知情的情况下,以用户的名义完成非法操作。这可能包括提交表单、在线支付、更改用户设置等操作,而受害者对此毫无察觉。

CSRF攻击通常利用了网站对用户身份验证机制的信任。如果网站仅仅通过验证用户的cookie来确认用户的请求,那么攻击者就可以构造外部请求,诱导已登录的用户点击链接或通过其他方式触发请求,从而在用户不知情的情况下,以用户的身份执行攻击者预设的操作。

防御

对于CSRF攻击,则常用的方法包括

  • 验证Referer头 这个可以在中间件进行处理,也可以在Nginx层就处理。下面不做处理讲解。

  • 使用SameSite Cookie属性等

    SameSite属性是一个用于防止跨站点请求伪造(CSRF)攻击的Cookie属性。它允许服务器指定Cookie是否应该与跨站点请求一起发送。这个属性可以有三个值:StrictLaxNone

    • Strict: 最为严格的设置。在这种模式下,Cookie只会在请求来自于同一个站点时被发送。这意味着,即使用户是通过点击第三方网站上的链接访问的,Cookie也不会被发送。 使用场景:适用于一些非常重视安全性,而且不需要跨站点请求携带用户状态的应用。
    • Lax: 相对宽松的设置。在这种模式下,Cookie会在一些跨站点的GET请求中被发送,例如用户通过第三方网站上的链接访问。但在POST请求或者通过脚本发起的请求(如XHR或Fetch)中,Cookie不会被发送。 使用场景:这是大多数网站的推荐设置,因为它在保护用户免受CSRF攻击的同时,还允许了一些用户友好的跨站点请求,比如从其他网站的链接直接访问。
    • None: 在这种模式下,Cookie会在所有的请求中被发送,无论是同站点还是跨站点请求。要使用SameSite=None,还必须将Secure属性设置为true,这意味着Cookie只能通过安全的HTTPS连接发送。 使用场景:适用于需要在多个站点间共享Cookie的应用,例如跨域身份验证。
  • 使用CSRF令牌;

    CSRF的验证有几种方法,这里使用对比验证即前端Request中Headers字段与Cookies字段进行比较验证。

    前端从后端获取到token,在发送请求时携带在headers中。后端在下发token时,同时在cookie中增加token。

Code it

在NestJS中,有一些配置是开箱即用,引用对应的插件会默认处理,如果你有自定义的需要,你可以在插件处配置一些自定义配置

我的项目中使用 @fastify/cookie,@fastify/csrf-protection 总共分为几部:

注册以及配置组件

在main.ts中注册并配置组件

import fastifyCookie from '@fastify/cookie';
import fastifyCsrf from '@fastify/csrf-protection';

    // 增加一些自定义项
    await app.register(fastifyCookie, {
      secret: process.env.CSRF_SECRET,
    });
    // // 注册 @fastify/csrf-protection 插件
    await app.register(fastifyCsrf, {
      cookieKey: process.env.CSRF_KEY,
      cookieOpts: {
        path: '/',
        maxAge: Number(process.env.CSRF_EXPIRE), // 配置生效时间,建议与JWT时间一致
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production', // 在开发环境下不使用https
        sameSite: 'lax', //  sameSite: 'none' | 'lax' | 'none' | 'strict' | boolean;
      },
    });
    

上面有一些环境变量

CSRF_SECRET = 12345678901234567890123456789012
CSRF_KEY = csrf-token # 这个是cookieName
CSRF_EXPIRE = 172800 # 2days,但是单位是s 

可配置字段

这是 @fastify/csrf-protection的可配置字段,具体可前往github仓库查阅文档

  interface FastifyCsrfProtectionOptionsBase {
    cookieKey?: string;
    cookieOpts?: CookieSerializeOptions;
    sessionKey?: string;
    getUserInfo?: (req: FastifyRequest) => string;
    getToken?: GetTokenFn;
  }

在登录时生成CSRF-TOKEN

因为csrf是借助已登录的凭据,则我们在登录时同时生成并下发token。

注意:为了保证CSRF生成不会和登录逻辑造成耦合,我将CSRF的生成独立成一个新的Response拦截器。

写一个csrf的response拦截器

使用isLogin方法判断是否是登录接口。这个接口就是判断当前request.url。

import { isLogin } from '@/utils';
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { FastifyReply } from 'fastify';

@Injectable()
export class CsrfInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const ctx = context.switchToHttp();
    const response = ctx.getResponse<FastifyReply>();
    const CSRF_DATA = {};
    const isLoginUrl = isLogin(ctx.getRequest().url);
    if (isLoginUrl) {
      CSRF_DATA[process.env.CSRF_KEY] = response.generateCsrf();
    }
    return next.handle().pipe(map((data) => ({ ...data, ...CSRF_DATA })));
  }
}

全局注册拦截器

import { CsrfInterceptor } from './interceptors/csrf.interceptor';
@Module({
  providers: [
  // CSRF拦截
    {
      provide: APP_INTERCEPTOR,
      useClass: CsrfInterceptor,
    },
    ]
 })

定义CSRF守卫

守卫的作用,就是在收到Request后,对csrf进行验证。

我们或许并不需要对所有接口进行CSRF的防范,所以在特定接口使用守卫就可以。 如果你需要全局守卫,你在app.module.ts中,全局注册守卫也是可以的

需要注意的是,这里是Fastify的实现方式,Express是不一样的

调用csrfProtection进行验证。 这个方法验证 cookie中的csrf headers中csrf的一致性。

import { CanActivate, ExecutionContext } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { FastifyReply, FastifyRequest } from 'fastify';

@Injectable()
export class CsrfGuard implements CanActivate {
  constructor(private readonly adapterHost: HttpAdapterHost) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    try {
      const http = context.switchToHttp();
      const request = http.getRequest<FastifyRequest>(),
        response = http.getResponse<FastifyReply>();
      const instance = this.adapterHost.httpAdapter.getInstance();
      instance.csrfProtection(request, response, () => {}); // 因为csrf的Protection的next是必须要存在,且一定是方法。所以给一个匿名空方法即可。
      return true;
    } catch (error) {
      throw error;
    }
  }
}

注意,csrfProtection的默认方法

补充说明:csrfProcetion获取请求体中csrf-token的默认方法。 所以如果你未在@fastify/csrf-protection配置项中重新定义getToken方法你的前端需要按照如下方式将csrf传入。

function getTokenDefault (req) {
  return (req.body && req.body._csrf) ||
    req.headers['csrf-token'] ||
    req.headers['xsrf-token'] ||
    req.headers['x-csrf-token'] ||
    req.headers['x-xsrf-token']
}

使用守卫

我们在特定的接口(controller)或者全局增加守卫

  • 示意接口使用
import { CsrfGuard } from '@/guards/csrf.guard';
@Controller('user')
export class UserController {
  constructor() {}
  @UseGuards(CsrfGuard)
  getInfo(userId:string) {
    return this.userService.getInfo(userId);
  }
 }
  • 示意全局使用
import { CsrfGuard } from '@/guards/csrf.guard';
@Module({
  providers: [
  // CSRF守卫
    {
      provide: APP_GUARD,
      useClass: CsrfGuard,
    },
    ]
 })

效果

下发token

image.png

image.png

请求附带token

image.png

完结,撒花❀❀❀❀❀❀