Voltar para o blog
NestJSRedisBackendSegurança

Rate limiting no NestJS com Redis: sliding window e limites por usuário

26 de maio de 20268 min de leiturapor Sávio Araújo

Por que rate limiting importa

Sem rate limiting, uma única origem pode:

  • Esgotar os recursos do servidor com flood de requests
  • Gerar custos inesperados em APIs de terceiros (OpenAI, Stripe, SMS)
  • Realizar brute-force em endpoints de autenticação
  • Causar indisponibilidade para outros usuários

Rate limiting é uma das defesas mais básicas e eficazes.

@nestjs/throttler: o ponto de partida

Para a maioria dos projetos, o módulo oficial resolve:

// app.module.ts
@Module({
  imports: [
    ThrottlerModule.forRoot([
      {
        name: "short",
        ttl: 1000,    // 1 segundo
        limit: 10,    // 10 requests por segundo
      },
      {
        name: "long",
        ttl: 60000,   // 1 minuto
        limit: 100,   // 100 requests por minuto
      },
    ]),
  ],
  providers: [
    {
      provide: APP_GUARD,
      useClass: ThrottlerGuard,
    },
  ],
})
export class AppModule {}

Para usar Redis como storage (necessário com múltiplas instâncias):

pnpm add @nest-lab/throttler-storage-redis
ThrottlerModule.forRoot({
  throttlers: [{ ttl: 60000, limit: 100 }],
  storage: new ThrottlerStorageRedis(redisClient),
})

Limites diferentes por rota

@Controller("auth")
export class AuthController {
  // Login: limite mais restrito contra brute-force
  @Post("login")
  @Throttle({ default: { ttl: 60000, limit: 5 } })
  async login(@Body() dto: LoginDto) {}
 
  // Refresh token: limite moderado
  @Post("refresh")
  @Throttle({ default: { ttl: 60000, limit: 20 } })
  async refresh(@Body() dto: RefreshDto) {}
 
  // Rota pública sem limite
  @Get("status")
  @SkipThrottle()
  status() {}
}

Sliding window com Redis: implementação manual

O @nestjs/throttler usa fixed window por padrão. Sliding window é mais preciso e evita o "burst" no início de cada janela.

@Injectable()
export class SlidingWindowRateLimiter {
  constructor(private readonly redis: Redis) {}
 
  async isAllowed(
    key: string,
    windowMs: number,
    maxRequests: number
  ): Promise<{ allowed: boolean; remaining: number; resetAt: Date }> {
    const now = Date.now();
    const windowStart = now - windowMs;
 
    // Script Lua: executa atomicamente no Redis
    const script = `
      local key = KEYS[1]
      local now = tonumber(ARGV[1])
      local windowStart = tonumber(ARGV[2])
      local maxRequests = tonumber(ARGV[3])
      local ttl = tonumber(ARGV[4])
 
      -- Remove requests fora da janela
      redis.call('ZREMRANGEBYSCORE', key, 0, windowStart)
 
      -- Conta requests na janela atual
      local count = redis.call('ZCARD', key)
 
      if count < maxRequests then
        -- Adiciona o request atual
        redis.call('ZADD', key, now, now)
        redis.call('PEXPIRE', key, ttl)
        return {1, maxRequests - count - 1}
      else
        return {0, 0}
      end
    `;
 
    const [allowed, remaining] = (await this.redis.eval(
      script,
      1,
      key,
      now.toString(),
      windowStart.toString(),
      maxRequests.toString(),
      windowMs.toString()
    )) as [number, number];
 
    return {
      allowed: allowed === 1,
      remaining,
      resetAt: new Date(now + windowMs),
    };
  }
}

Guard usando o sliding window

@Injectable()
export class RateLimitGuard implements CanActivate {
  constructor(private readonly limiter: SlidingWindowRateLimiter) {}
 
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const response = context.switchToHttp().getResponse();
 
    // Limite por usuário autenticado ou por IP
    const identifier = request.user?.id ?? request.ip;
    const route = `${request.method}:${request.route?.path}`;
    const key = `ratelimit:${route}:${identifier}`;
 
    const { allowed, remaining, resetAt } = await this.limiter.isAllowed(
      key,
      60_000,  // 1 minuto
      100      // 100 requests
    );
 
    // Headers informativos para o cliente
    response.setHeader("X-RateLimit-Remaining", remaining);
    response.setHeader("X-RateLimit-Reset", resetAt.toISOString());
 
    if (!allowed) {
      throw new HttpException(
        {
          statusCode: 429,
          message: "Muitas requisições. Tente novamente em breve.",
          retryAfter: resetAt.toISOString(),
        },
        HttpStatus.TOO_MANY_REQUESTS
      );
    }
 
    return true;
  }
}

Limites múltiplos em cascata

Para endpoints críticos, aplique limites em múltiplas janelas:

async isAllowedMulti(identifier: string): Promise<boolean> {
  const limits = [
    { window: 1_000,    max: 5 },    // 5 por segundo
    { window: 60_000,   max: 100 },  // 100 por minuto
    { window: 3_600_000, max: 1000 }, // 1000 por hora
  ];
 
  for (const { window, max } of limits) {
    const key = `ratelimit:${window}:${identifier}`;
    const { allowed } = await this.isAllowed(key, window, max);
    if (!allowed) return false;
  }
 
  return true;
}

Rate limiting em endpoints de autenticação

Endpoints de login e recuperação de senha merecem tratamento especial:

@Injectable()
export class AuthRateLimitGuard implements CanActivate {
  constructor(private readonly limiter: SlidingWindowRateLimiter) {}
 
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const ip = request.ip;
    const email = request.body?.email;
 
    // Limite por IP
    const byIp = await this.limiter.isAllowed(`auth:ip:${ip}`, 300_000, 10);
    if (!byIp.allowed) throw new HttpException("Rate limit excedido", 429);
 
    // Limite por email (se informado)
    if (email) {
      const byEmail = await this.limiter.isAllowed(`auth:email:${email}`, 300_000, 5);
      if (!byEmail.allowed) throw new HttpException("Rate limit excedido", 429);
    }
 
    return true;
  }
}

Isso evita que um atacante tente múltiplas senhas para o mesmo email de IPs diferentes.

Conclusão

Para a maioria dos projetos, @nestjs/throttler com storage Redis resolve bem. Para casos que precisam de sliding window real, controle por múltiplos identificadores ou limites em cascata, a implementação manual com Lua script no Redis é a abordagem mais precisa e performática.

O ponto crítico é sempre usar Redis (ou equivalente) como storage: nunca mantenha estado de rate limiting em memória quando há mais de uma instância da aplicação.

Quer conversar sobre esse tema?

Se você tem dúvidas, quer aplicar isso num projeto ou só quer trocar uma ideia, pode me chamar.

Falar com a SA Tech