rate-limit.guard.ts 2.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
  1. import {
  2. CanActivate,
  3. ExecutionContext,
  4. Injectable,
  5. Logger,
  6. HttpException,
  7. HttpStatus,
  8. } from '@nestjs/common';
  9. import type { FastifyRequest } from 'fastify';
  10. interface RateLimitBucket {
  11. count: number;
  12. resetAt: number;
  13. }
  14. @Injectable()
  15. export class RateLimitGuard implements CanActivate {
  16. private readonly logger = new Logger(RateLimitGuard.name);
  17. private readonly buckets = new Map<string, RateLimitBucket>();
  18. private readonly limit = 10; // Max requests
  19. private readonly windowMs = 60_000; // 1 minute window
  20. private readonly cleanupInterval: NodeJS.Timeout;
  21. constructor() {
  22. // Cleanup expired buckets every 5 minutes
  23. this.cleanupInterval = setInterval(() => {
  24. this.cleanup();
  25. }, 5 * 60_000);
  26. }
  27. canActivate(context: ExecutionContext): boolean {
  28. const request = context.switchToHttp().getRequest<FastifyRequest>();
  29. const ip = this.getClientIp(request);
  30. const endpoint = `${request.method}:${request.url.split('?')[0]}`;
  31. const key = `${ip}:${endpoint}`;
  32. const now = Date.now();
  33. let bucket = this.buckets.get(key);
  34. if (!bucket || now > bucket.resetAt) {
  35. // Create new bucket or reset expired one
  36. bucket = {
  37. count: 1,
  38. resetAt: now + this.windowMs,
  39. };
  40. this.buckets.set(key, bucket);
  41. return true;
  42. }
  43. bucket.count++;
  44. if (bucket.count > this.limit) {
  45. const retryAfter = Math.ceil((bucket.resetAt - now) / 1000);
  46. this.logger.warn(
  47. `Rate limit exceeded for ${ip} on ${endpoint} (${bucket.count}/${this.limit})`,
  48. );
  49. throw new HttpException(
  50. {
  51. statusCode: 429,
  52. message: `Too many requests. Please try again in ${retryAfter} seconds.`,
  53. code: 'RATE_LIMITED',
  54. retryAfter,
  55. },
  56. HttpStatus.TOO_MANY_REQUESTS,
  57. );
  58. }
  59. return true;
  60. }
  61. private getClientIp(request: FastifyRequest): string {
  62. // Check common headers for real IP
  63. const forwardedFor = request.headers['x-forwarded-for'];
  64. if (forwardedFor) {
  65. const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor;
  66. return ips.split(',')[0].trim();
  67. }
  68. const realIp = request.headers['x-real-ip'];
  69. if (realIp) {
  70. return Array.isArray(realIp) ? realIp[0] : realIp;
  71. }
  72. return request.ip || 'unknown';
  73. }
  74. private cleanup() {
  75. const now = Date.now();
  76. let cleaned = 0;
  77. for (const [key, bucket] of this.buckets.entries()) {
  78. if (now > bucket.resetAt) {
  79. this.buckets.delete(key);
  80. cleaned++;
  81. }
  82. }
  83. if (cleaned > 0) {
  84. this.logger.debug(`Cleaned up ${cleaned} expired rate limit buckets`);
  85. }
  86. }
  87. onModuleDestroy() {
  88. if (this.cleanupInterval) {
  89. clearInterval(this.cleanupInterval);
  90. }
  91. }
  92. }