From 6b597d146682edbc0e300b7555d3a5ee2baefa1c Mon Sep 17 00:00:00 2001 From: shamoo53 Date: Thu, 23 Apr 2026 11:46:57 +0100 Subject: [PATCH] perf(database): CSRF Protection --- src/app.module.ts | 7 ++ .../examples/timeout-example.controller.ts | 43 +++++++ .../interceptors/timeout.interceptor.ts | 31 +++++- src/common/timeout/timeout-config.service.ts | 105 ++++++++++++++++++ src/common/timeout/timeout.controller.ts | 74 ++++++++++++ src/common/timeout/timeout.module.ts | 11 ++ src/main.ts | 3 +- 7 files changed, 269 insertions(+), 5 deletions(-) create mode 100644 src/common/examples/timeout-example.controller.ts create mode 100644 src/common/timeout/timeout-config.service.ts create mode 100644 src/common/timeout/timeout.controller.ts create mode 100644 src/common/timeout/timeout.module.ts diff --git a/src/app.module.ts b/src/app.module.ts index c0317eae..99867b0d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -21,6 +21,7 @@ import { ThrottlerModule } from '@nestjs/throttler'; import { CustomThrottleGuard } from './common/guards/throttle.guard'; import { loadFeatureFlags } from './config/feature-flags.config'; import { StartupLogger } from './common/lazy-loading/startup-logger.service'; +import { TimeoutInterceptor } from './common/interceptors/timeout.interceptor'; // Feature modules - conditionally loaded based on feature flags import { SyncModule } from './sync/sync.module'; @@ -51,6 +52,7 @@ import { AuthModule } from './auth/auth.module'; import { PaymentsModule } from './payments/payments.module'; import { LocalizationModule } from './localization/localization.module'; import { CsrfModule } from './common/csrf/csrf.module'; +import { TimeoutModule } from './common/timeout/timeout.module'; @Module({}) export class AppModule { @@ -128,6 +130,7 @@ export class AppModule { HealthModule, DatabaseModule, CsrfModule, + TimeoutModule, ]; // Feature modules - conditionally loaded based on feature flags @@ -381,6 +384,10 @@ export class AppModule { provide: APP_INTERCEPTOR, useClass: MonitoringInterceptor, }, + { + provide: APP_INTERCEPTOR, + useClass: TimeoutInterceptor, + }, { provide: APP_GUARD, useClass: CustomThrottleGuard, diff --git a/src/common/examples/timeout-example.controller.ts b/src/common/examples/timeout-example.controller.ts new file mode 100644 index 00000000..147215a6 --- /dev/null +++ b/src/common/examples/timeout-example.controller.ts @@ -0,0 +1,43 @@ +import { Controller, Get, Post, Body } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { Timeout } from '../interceptors/timeout.interceptor'; + +@ApiTags('Timeout Examples') +@Controller('examples') +export class TimeoutExampleController { + @Get('quick') + @ApiOperation({ summary: 'Quick endpoint with custom timeout' }) + @Timeout(5000) // 5 seconds timeout + getQuickResponse(): { message: string } { + // This endpoint will timeout after 5 seconds + return { message: 'Quick response' }; + } + + @Get('slow') + @ApiOperation({ summary: 'Slow endpoint with longer timeout' }) + @Timeout(120000) // 2 minutes timeout + async getSlowResponse(): Promise<{ message: string }> { + // Simulate a slow operation + await new Promise((resolve) => setTimeout(resolve, 10000)); // 10 second delay + return { message: 'Slow response completed' }; + } + + @Post('process') + @ApiOperation({ summary: 'Processing endpoint with custom timeout' }) + @Timeout(60000) // 1 minute timeout + async processData(@Body() data: any): Promise<{ result: string }> { + // Simulate data processing + await new Promise((resolve) => setTimeout(resolve, 30000)); // 30 second processing + return { result: 'Data processed successfully' }; + } + + @Get('default') + @ApiOperation({ summary: 'Endpoint using default timeout' }) + getDefaultTimeout(): { message: string; timeout: string } { + // This endpoint will use the default timeout from configuration + return { + message: 'Using default timeout', + timeout: 'Configured by REQUEST_TIMEOUT env var or default 30s', + }; + } +} diff --git a/src/common/interceptors/timeout.interceptor.ts b/src/common/interceptors/timeout.interceptor.ts index 4e9e26bb..ba69c607 100644 --- a/src/common/interceptors/timeout.interceptor.ts +++ b/src/common/interceptors/timeout.interceptor.ts @@ -4,11 +4,14 @@ import { ExecutionContext, CallHandler, BadGatewayException, + Logger, + Inject, } from '@nestjs/common'; import { Observable, TimeoutError } from 'rxjs'; import { timeout, catchError } from 'rxjs/operators'; +import { TimeoutConfigService } from '../timeout/timeout-config.service'; -export const DEFAULT_TIMEOUT = parseInt(process.env.REQUEST_TIMEOUT || '10000', 10); // ms +export const DEFAULT_TIMEOUT = parseInt(process.env.REQUEST_TIMEOUT || '30000', 10); // 30 seconds default export function Timeout(ms?: number): MethodDecorator { return (target, propertyKey, descriptor) => { @@ -18,19 +21,41 @@ export function Timeout(ms?: number): MethodDecorator { @Injectable() export class TimeoutInterceptor implements NestInterceptor { + private readonly logger = new Logger(TimeoutInterceptor.name); + + constructor(@Inject(TimeoutConfigService) private timeoutConfig: TimeoutConfigService) {} + intercept(context: ExecutionContext, next: CallHandler): Observable { const handler = context.getHandler(); + const controller = context.getClass(); const customTimeout = Reflect.getMetadata('timeout', handler); - const timeoutValue = customTimeout || DEFAULT_TIMEOUT; + + const request = context.switchToHttp().getRequest(); + const method = request.method; + const url = request.url; + + // Determine timeout value with priority: decorator > config service > default + let timeoutValue: number; + if (customTimeout) { + timeoutValue = customTimeout; + this.logger.debug(`Using decorator timeout of ${timeoutValue}ms for ${method} ${url}`); + } else { + timeoutValue = this.timeoutConfig.getTimeoutForRequest(method, url); + this.logger.debug(`Using config timeout of ${timeoutValue}ms for ${method} ${url}`); + } + return next.handle().pipe( timeout(timeoutValue), catchError((err) => { if (err instanceof TimeoutError) { + this.logger.warn(`Request timeout: ${method} ${url} after ${timeoutValue}ms`); throw new BadGatewayException({ statusCode: 504, - message: 'Request timed out', + message: `Request timed out after ${timeoutValue}ms`, error: 'Timeout', timestamp: new Date().toISOString(), + path: url, + method, }); } throw err; diff --git a/src/common/timeout/timeout-config.service.ts b/src/common/timeout/timeout-config.service.ts new file mode 100644 index 00000000..3fc6deab --- /dev/null +++ b/src/common/timeout/timeout-config.service.ts @@ -0,0 +1,105 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export interface TimeoutConfig { + default: number; + endpoints: Record; + methods: Record; +} + +@Injectable() +export class TimeoutConfigService { + private readonly config: TimeoutConfig; + + constructor(private configService: ConfigService) { + this.config = this.loadConfig(); + } + + private loadConfig(): TimeoutConfig { + return { + default: parseInt(process.env.REQUEST_TIMEOUT || '30000', 10), // 30 seconds + endpoints: { + // API endpoints with custom timeouts + '/auth/login': 10000, // 10 seconds for login + '/auth/register': 15000, // 15 seconds for registration + '/payments/create-payment-intent': 20000, // 20 seconds for payment processing + '/media/upload': 120000, // 2 minutes for file upload + '/backup/create': 300000, // 5 minutes for backup creation + '/search': 15000, // 15 seconds for search + '/email-marketing/campaigns/send': 60000, // 1 minute for campaign sending + }, + methods: { + // HTTP method-specific timeouts + 'GET': 30000, // 30 seconds for GET requests + 'POST': 60000, // 1 minute for POST requests + 'PUT': 45000, // 45 seconds for PUT requests + 'DELETE': 30000, // 30 seconds for DELETE requests + 'PATCH': 45000, // 45 seconds for PATCH requests + }, + }; + } + + getDefaultTimeout(): number { + return this.config.default; + } + + getEndpointTimeout(path: string): number | null { + // Check for exact path match + if (this.config.endpoints[path]) { + return this.config.endpoints[path]; + } + + // Check for pattern matches + for (const [pattern, timeout] of Object.entries(this.config.endpoints)) { + if (this.matchesPattern(path, pattern)) { + return timeout; + } + } + + return null; + } + + getMethodTimeout(method: string): number | null { + return this.config.methods[method.toUpperCase()] || null; + } + + getTimeoutForRequest(method: string, path: string): number { + // Priority: endpoint > method > default + const endpointTimeout = this.getEndpointTimeout(path); + if (endpointTimeout) { + return endpointTimeout; + } + + const methodTimeout = this.getMethodTimeout(method); + if (methodTimeout) { + return methodTimeout; + } + + return this.getDefaultTimeout(); + } + + private matchesPattern(path: string, pattern: string): boolean { + // Simple pattern matching - can be enhanced with regex + if (pattern.includes('*')) { + const regexPattern = pattern.replace(/\*/g, '.*'); + return new RegExp(`^${regexPattern}$`).test(path); + } + return false; + } + + updateEndpointTimeout(path: string, timeout: number): void { + this.config.endpoints[path] = timeout; + } + + updateMethodTimeout(method: string, timeout: number): void { + this.config.methods[method.toUpperCase()] = timeout; + } + + updateDefaultTimeout(timeout: number): void { + this.config.default = timeout; + } + + getConfig(): TimeoutConfig { + return { ...this.config }; + } +} diff --git a/src/common/timeout/timeout.controller.ts b/src/common/timeout/timeout.controller.ts new file mode 100644 index 00000000..5b77d467 --- /dev/null +++ b/src/common/timeout/timeout.controller.ts @@ -0,0 +1,74 @@ +import { Controller, Get, Put, UseGuards, Body, HttpCode, HttpStatus } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { TimeoutConfigService, TimeoutConfig } from './timeout-config.service'; + +@ApiTags('Timeout Configuration') +@ApiBearerAuth() +@Controller('timeout') +@UseGuards(JwtAuthGuard) +export class TimeoutController { + constructor(private readonly timeoutConfig: TimeoutConfigService) {} + + @Get('config') + @ApiOperation({ summary: 'Get current timeout configuration' }) + @ApiResponse({ status: 200, description: 'Timeout configuration retrieved successfully' }) + getConfig(): TimeoutConfig { + return this.timeoutConfig.getConfig(); + } + + @Put('default') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Update default timeout' }) + @ApiResponse({ status: 200, description: 'Default timeout updated successfully' }) + updateDefaultTimeout(@Body() body: { timeout: number }): { message: string; timeout: number } { + this.timeoutConfig.updateDefaultTimeout(body.timeout); + return { + message: 'Default timeout updated successfully', + timeout: body.timeout, + }; + } + + @Put('endpoint') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Update endpoint timeout' }) + @ApiResponse({ status: 200, description: 'Endpoint timeout updated successfully' }) + updateEndpointTimeout(@Body() body: { path: string; timeout: number }): { message: string } { + this.timeoutConfig.updateEndpointTimeout(body.path, body.timeout); + return { + message: `Endpoint timeout updated successfully for ${body.path}`, + }; + } + + @Put('method') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Update HTTP method timeout' }) + @ApiResponse({ status: 200, description: 'Method timeout updated successfully' }) + updateMethodTimeout(@Body() body: { method: string; timeout: number }): { message: string } { + this.timeoutConfig.updateMethodTimeout(body.method, body.timeout); + return { + message: `Method timeout updated successfully for ${body.method}`, + }; + } + + @Get('check') + @ApiOperation({ summary: 'Get timeout for a specific request' }) + @ApiResponse({ status: 200, description: 'Timeout calculated successfully' }) + checkTimeout(@Body() body: { method: string; path: string }): { timeout: number; source: string } { + const timeout = this.timeoutConfig.getTimeoutForRequest(body.method, body.path); + const endpointTimeout = this.timeoutConfig.getEndpointTimeout(body.path); + const methodTimeout = this.timeoutConfig.getMethodTimeout(body.method); + + let source = 'default'; + if (endpointTimeout) { + source = 'endpoint'; + } else if (methodTimeout) { + source = 'method'; + } + + return { + timeout, + source, + }; + } +} diff --git a/src/common/timeout/timeout.module.ts b/src/common/timeout/timeout.module.ts new file mode 100644 index 00000000..e2f60ba8 --- /dev/null +++ b/src/common/timeout/timeout.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TimeoutConfigService } from './timeout-config.service'; +import { TimeoutController } from './timeout.controller'; +import { TimeoutExampleController } from '../examples/timeout-example.controller'; + +@Module({ + providers: [TimeoutConfigService], + controllers: [TimeoutController, TimeoutExampleController], + exports: [TimeoutConfigService], +}) +export class TimeoutModule {} diff --git a/src/main.ts b/src/main.ts index 12152bc1..2fec2910 100644 --- a/src/main.ts +++ b/src/main.ts @@ -82,8 +82,7 @@ async function bootstrapWorker() { app.useGlobalInterceptors(new ResponseTransformInterceptor()); // ─── Global Timeout Interceptor ───────────────────────────────────────── - const { TimeoutInterceptor } = await import('./common/interceptors/timeout.interceptor'); - app.useGlobalInterceptors(new TimeoutInterceptor()); + // TimeoutInterceptor is now provided globally via APP_INTERCEPTOR in AppModule // ─── CORS ───────────────────────────────────────────────────────────────── app.enableCors();