diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 5643676..9ae0598 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module, NestModule, MiddlewareConsumer, RequestMethod } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { ConfigModule } from '@nestjs/config'; @@ -13,6 +13,7 @@ import { APP_GUARD } from '@nestjs/core'; import { HealthModule } from './health/health.module'; import { LoggerModule } from './logger/logger.module'; import { QueueModule } from './queue/queue.module'; +import { LoggerMiddleware } from './common/middleware/logger.middleware'; @Module({ imports: [ @@ -21,8 +22,8 @@ import { QueueModule } from './queue/queue.module'; }), ThrottlerModule.forRoot([ { - ttl: 60000, // 60 seconds in milliseconds - limit: 100, // 100 requests per 60 seconds + ttl: 60000, + limit: 100, }, ]), LoggerModule, @@ -44,4 +45,10 @@ import { QueueModule } from './queue/queue.module'; }, ], }) -export class AppModule {} +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer + .apply(LoggerMiddleware) + .forRoutes({ path: '*', method: RequestMethod.ALL }); + } +} diff --git a/backend/src/common/middleware/logger.middleware.ts b/backend/src/common/middleware/logger.middleware.ts new file mode 100644 index 0000000..81fa20b --- /dev/null +++ b/backend/src/common/middleware/logger.middleware.ts @@ -0,0 +1,97 @@ +import { + Injectable, + NestMiddleware, + LoggerService, +} from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { WinstonLogger } from '../logger/winston.logger'; + +/* ---------- sensitive field redaction ---------- */ + +const SENSITIVE_FIELDS = [ + 'password', + 'password_confirmation', + 'currentPassword', + 'newPassword', + 'token', + 'accessToken', + 'refreshToken', + 'apiKey', + 'secret', + 'authorization', + 'creditCard', + 'ssn', +]; + +function redactSensitive(obj: Record): Record { + if (!obj || typeof obj !== 'object') return obj; + const redacted = { ...obj }; + for (const key of Object.keys(redacted)) { + const lowerKey = key.toLowerCase(); + if (SENSITIVE_FIELDS.some((s) => lowerKey.includes(s.toLowerCase()))) { + (redacted as any)[key] = '[REDACTED]'; + } else if (typeof (redacted as any)[key] === 'object' && (redacted as any)[key] !== null) { + (redacted as any)[key] = redactSensitive((redacted as any)[key] as Record); + } + } + return redacted; +} + +/* ---------- middleware ---------- */ + +@Injectable() +export class LoggerMiddleware implements NestMiddleware { + private logger: WinstonLogger; + + constructor() { + this.logger = new WinstonLogger('HTTP'); + } + + use(req: Request, res: Response, next: NextFunction): void { + const start = Date.now(); + const { method, originalUrl, ip, headers, body } = req; + + // Capture response finish + res.on('finish', () => { + const duration = Date.now() - start; + const statusCode = res.statusCode; + const contentLength = res.getHeader('content-length'); + + // Build safe log entry + const logData: Record = { + method, + url: originalUrl, + ip: this.extractIp(req), + statusCode, + durationMs: duration, + contentLength: contentLength ?? 0, + userAgent: headers['user-agent']?.substring(0, 120), + }; + + // Include body for non-GET/DELETE requests (redacted) + if (!['GET', 'HEAD', 'OPTIONS'].includes(method) && body && Object.keys(body).length > 0) { + logData.body = redactSensitive(body as Record); + } + + // Log level based on status code + const level = statusCode >= 500 ? 'error' : statusCode >= 400 ? 'warn' : 'info'; + this.logger[level]?.( + `${method} ${originalUrl} - ${statusCode} - ${duration}ms`, + 'HTTP', + ); + }); + + next(); + } + + /** + * Extract client IP, respecting reverse proxy headers. + */ + private extractIp(req: Request): string { + const forwarded = req.headers['x-forwarded-for']; + if (typeof forwarded === 'string') { + return forwarded.split(',')[0].trim(); + } + return req.socket.remoteAddress || req.ip || 'unknown'; + } +}