From 3c85a3eb575307184cab8a27c395defff1fe698b Mon Sep 17 00:00:00 2001 From: gabito1451 Date: Wed, 22 Apr 2026 14:57:01 -0700 Subject: [PATCH] feat: implement global rate limiting configuration (closes #266) --- src/app.module.ts | 2 +- src/auth/auth.controller.ts | 4 +++ src/common/guards/throttle.guard.ts | 37 +++++++-------------- src/health/health.controller.ts | 2 ++ src/media/media.controller.ts | 2 ++ src/payments/webhooks/webhook.controller.ts | 2 ++ src/search/search.controller.ts | 2 ++ 7 files changed, 25 insertions(+), 26 deletions(-) diff --git a/src/app.module.ts b/src/app.module.ts index d8393dd5..4e91550e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -121,7 +121,7 @@ export class AppModule { ThrottlerModule.forRoot([ { ttl: parseInt(process.env.THROTTLE_TTL || '60'), - limit: parseInt(process.env.THROTTLE_LIMIT || '10'), + limit: parseInt(process.env.THROTTLE_LIMIT || '60'), }, ]), HealthModule, diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 902fe900..159bb1f3 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -39,6 +39,7 @@ export class AuthController { } @Post('refresh') + @Throttle({ default: { limit: 20, ttl: 60000 } }) // 20 requests per minute @ApiOperation({ summary: 'Refresh access token using refresh token' }) async refresh(@Body() refreshTokenDto: RefreshTokenDto) { return this.authService.refreshToken(refreshTokenDto.refreshToken); @@ -55,12 +56,14 @@ export class AuthController { } @Post('forgot-password') + @Throttle({ default: { limit: 5, ttl: 3600000 } }) // 5 requests per hour @ApiOperation({ summary: 'Request a password reset link' }) async forgotPassword(@Body() forgotPasswordDto: ForgotPasswordDto) { return this.authService.forgotPassword(forgotPasswordDto.email); } @Post('reset-password') + @Throttle({ default: { limit: 5, ttl: 3600000 } }) // 5 requests per hour @ApiOperation({ summary: 'Reset password using token' }) async resetPassword(@Body() resetPasswordDto: ResetPasswordDto) { return this.authService.resetPassword(resetPasswordDto); @@ -81,6 +84,7 @@ export class AuthController { } @Post('verify-email') + @Throttle({ default: { limit: 10, ttl: 3600000 } }) // 10 requests per hour @ApiOperation({ summary: 'Verify email using token' }) async verifyEmail(@Body() verifyEmailDto: VerifyEmailDto) { return this.authService.verifyEmail(verifyEmailDto.token); diff --git a/src/common/guards/throttle.guard.ts b/src/common/guards/throttle.guard.ts index ca10f3af..4925dad8 100644 --- a/src/common/guards/throttle.guard.ts +++ b/src/common/guards/throttle.guard.ts @@ -1,5 +1,5 @@ import { Injectable, ExecutionContext, Logger, HttpException, HttpStatus } from '@nestjs/common'; -import { ThrottlerGuard } from '@nestjs/throttler'; +import { ThrottlerGuard, ThrottlerLimitDetail } from '@nestjs/throttler'; import { Request, Response } from 'express'; /** @@ -15,7 +15,10 @@ export class CustomThrottleGuard extends ThrottlerGuard { private readonly logger = new Logger(CustomThrottleGuard.name); /** Called by ThrottlerGuard when the limit is exceeded. */ - protected override throwThrottlingException(context: ExecutionContext): Promise { + protected override async throwThrottlingException( + context: ExecutionContext, + throttlerLimitDetail: ThrottlerLimitDetail, + ): Promise { const request = context.switchToHttp().getRequest(); const response = context.switchToHttp().getResponse(); @@ -24,21 +27,21 @@ export class CustomThrottleGuard extends ThrottlerGuard { this.logger.warn(`Rate limit exceeded: ip=${ip} method=${request.method} route=${route}`); - // Get throttle options from the decorator or default - const throttleOptions = this.getThrottleOptions(context); - // Inject standard rate-limit headers so clients can back off gracefully - response.setHeader('Retry-After', throttleOptions.ttl); - response.setHeader('X-RateLimit-Limit', throttleOptions.limit); + // TTL in v6 is in seconds if defined that way in config, but throttlerLimitDetail.ttl is the value from config + const ttlSeconds = throttlerLimitDetail.ttl; + + response.setHeader('Retry-After', ttlSeconds); + response.setHeader('X-RateLimit-Limit', throttlerLimitDetail.limit); response.setHeader('X-RateLimit-Remaining', 0); - response.setHeader('X-RateLimit-Reset', Math.floor(Date.now() / 1000) + throttleOptions.ttl); + response.setHeader('X-RateLimit-Reset', Math.floor(Date.now() / 1000) + ttlSeconds); throw new HttpException( { statusCode: HttpStatus.TOO_MANY_REQUESTS, error: 'Too Many Requests', message: 'You have exceeded the request rate limit. Please wait before retrying.', - retryAfterSeconds: throttleOptions.ttl, + retryAfterSeconds: ttlSeconds, }, HttpStatus.TOO_MANY_REQUESTS, ); @@ -49,20 +52,4 @@ export class CustomThrottleGuard extends ThrottlerGuard { if (typeof forwarded === 'string') return forwarded.split(',')[0].trim(); return request.ip ?? request.socket?.remoteAddress ?? 'unknown'; } - - private getThrottleOptions(context: ExecutionContext): { limit: number; ttl: number } { - // Try to get throttle options from the decorator - const handler = context.getHandler(); - const throttleDecorator = Reflect.getMetadata('__throttler__', handler); - - if (throttleDecorator) { - return { - limit: throttleDecorator.limit || 10, - ttl: throttleDecorator.ttl || 60, - }; - } - - // Fallback to default options - return { limit: 10, ttl: 60 }; - } } diff --git a/src/health/health.controller.ts b/src/health/health.controller.ts index 86c9271a..ea2a123b 100644 --- a/src/health/health.controller.ts +++ b/src/health/health.controller.ts @@ -1,8 +1,10 @@ import { Controller, Get } from '@nestjs/common'; import { DataSource } from 'typeorm'; import Redis from 'ioredis'; +import { SkipThrottle } from '@nestjs/throttler'; import { HealthService } from './health.service'; +@SkipThrottle() @Controller('health') export class HealthController { private redis: Redis; diff --git a/src/media/media.controller.ts b/src/media/media.controller.ts index 5811c979..1535d153 100644 --- a/src/media/media.controller.ts +++ b/src/media/media.controller.ts @@ -13,6 +13,7 @@ import { Body, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; +import { Throttle } from '@nestjs/throttler'; import { ApiTags, ApiOperation, @@ -35,6 +36,7 @@ export class MediaController { constructor(private readonly mediaService: MediaService) {} @Post('upload') + @Throttle({ default: { limit: 10, ttl: 3600000 } }) @UseGuards(JwtAuthGuard) @UseInterceptors(FileInterceptor('file')) @ApiOperation({ summary: 'Upload media file with full validation' }) diff --git a/src/payments/webhooks/webhook.controller.ts b/src/payments/webhooks/webhook.controller.ts index 01be80f9..ec053e59 100644 --- a/src/payments/webhooks/webhook.controller.ts +++ b/src/payments/webhooks/webhook.controller.ts @@ -10,8 +10,10 @@ import { } from '@nestjs/common'; import { Request } from 'express'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { SkipThrottle } from '@nestjs/throttler'; import { WebhookService } from './webhook.service'; +@SkipThrottle() @ApiTags('webhooks') @Controller('webhooks') export class WebhookController { diff --git a/src/search/search.controller.ts b/src/search/search.controller.ts index fcb8c7c4..7a78d7ca 100644 --- a/src/search/search.controller.ts +++ b/src/search/search.controller.ts @@ -1,6 +1,8 @@ import { BadRequestException, Controller, Get, Query } from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; import { SearchService } from './search.service'; +@Throttle({ default: { limit: 30, ttl: 60000 } }) @Controller('search') export class SearchController { constructor(private readonly searchService: SearchService) {}