From f3081d92fca03f434fac94e656b9a87a07f7f743 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Wed, 8 Apr 2026 02:04:13 +0700 Subject: [PATCH] =?UTF-8?q?feat(security):=20add=20security=20hardening=20?= =?UTF-8?q?=E2=80=94=20Helmet,=20CORS,=20rate=20limiting,=20input=20saniti?= =?UTF-8?q?zation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Helmet with CSP, HSTS, referrer policy - Configure CORS with environment-based origins - Add global validation pipe with whitelist mode - Add SanitizeInputMiddleware for XSS prevention - Add ThrottlerBehindProxyGuard for rate limiting - Add FileValidationPipe for upload security - Set request body size limit to 1MB Co-Authored-By: Paperclip --- apps/api/src/main.ts | 61 +++++++++++++++++++ .../guards/throttler-behind-proxy.guard.ts | 17 ++++++ .../modules/shared/infrastructure/index.ts | 4 ++ .../middleware/sanitize-input.middleware.ts | 50 +++++++++++++++ .../pipes/file-validation.pipe.ts | 61 +++++++++++++++++++ apps/api/src/modules/shared/shared.module.ts | 5 +- 6 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/modules/shared/infrastructure/guards/throttler-behind-proxy.guard.ts create mode 100644 apps/api/src/modules/shared/infrastructure/middleware/sanitize-input.middleware.ts create mode 100644 apps/api/src/modules/shared/infrastructure/pipes/file-validation.pipe.ts diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index adb68ed..02b4cfe 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,5 +1,7 @@ import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; import { LoggerService } from '@modules/shared'; +import helmet from 'helmet'; import { AppModule } from './app.module'; async function bootstrap() { @@ -7,6 +9,65 @@ async function bootstrap() { const logger = app.get(LoggerService); app.useLogger(logger); + // ── Security Headers (Helmet) ── + app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", 'data:', 'https:'], + connectSrc: ["'self'"], + fontSrc: ["'self'"], + objectSrc: ["'none'"], + frameSrc: ["'none'"], + baseUri: ["'self'"], + formAction: ["'self'"], + }, + }, + crossOriginEmbedderPolicy: true, + crossOriginOpenerPolicy: true, + crossOriginResourcePolicy: { policy: 'same-origin' }, + hsts: { maxAge: 31536000, includeSubDomains: true, preload: true }, + referrerPolicy: { policy: 'strict-origin-when-cross-origin' }, + }), + ); + + // ── CORS ── + const allowedOrigins = (process.env['CORS_ORIGINS'] ?? 'http://localhost:3000') + .split(',') + .map((o) => o.trim()); + app.enableCors({ + origin: allowedOrigins, + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Correlation-Id'], + exposedHeaders: ['X-Correlation-Id'], + credentials: true, + maxAge: 86400, + }); + + // ── Global Validation Pipe ── + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { enableImplicitConversion: true }, + disableErrorMessages: process.env['NODE_ENV'] === 'production', + }), + ); + + // ── Request Body Size Limit ── + // Express default is 100kb; explicitly set for clarity + const expressApp = app.getHttpAdapter().getInstance(); + const { json, urlencoded } = await import('express'); + expressApp.use(json({ limit: '1mb' })); + expressApp.use(urlencoded({ extended: true, limit: '1mb' })); + + // ── Trust Proxy (for rate limiting behind reverse proxy) ── + expressApp.set('trust proxy', 1); + const port = process.env['PORT'] ?? 3001; await app.listen(port); logger.log(`API running on http://localhost:${port}`, 'Bootstrap'); diff --git a/apps/api/src/modules/shared/infrastructure/guards/throttler-behind-proxy.guard.ts b/apps/api/src/modules/shared/infrastructure/guards/throttler-behind-proxy.guard.ts new file mode 100644 index 0000000..c8ccdf9 --- /dev/null +++ b/apps/api/src/modules/shared/infrastructure/guards/throttler-behind-proxy.guard.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; +import { ThrottlerGuard } from '@nestjs/throttler'; +import type { Request } from 'express'; + +/** + * Extends ThrottlerGuard to extract real client IP behind reverse proxies + * (e.g., nginx, CloudFlare, AWS ALB) using X-Forwarded-For header. + */ +@Injectable() +export class ThrottlerBehindProxyGuard extends ThrottlerGuard { + protected override getTracker(req: Request): Promise { + const forwarded = req.headers['x-forwarded-for']; + const ip = + typeof forwarded === 'string' ? (forwarded.split(',')[0]?.trim() ?? '127.0.0.1') : req.ip; + return Promise.resolve(ip ?? '127.0.0.1'); + } +} diff --git a/apps/api/src/modules/shared/infrastructure/index.ts b/apps/api/src/modules/shared/infrastructure/index.ts index d10b45d..26285a8 100644 --- a/apps/api/src/modules/shared/infrastructure/index.ts +++ b/apps/api/src/modules/shared/infrastructure/index.ts @@ -5,4 +5,8 @@ export { EventBusService } from './event-bus.service'; export { GlobalExceptionFilter } from './filters/global-exception.filter'; export { CorrelationIdMiddleware } from './middleware/correlation-id.middleware'; export { RequestLoggingMiddleware } from './middleware/request-logging.middleware'; +export { SanitizeInputMiddleware } from './middleware/sanitize-input.middleware'; export { maskPii } from './pii-masker'; +export { ThrottlerBehindProxyGuard } from './guards/throttler-behind-proxy.guard'; +export { FileValidationPipe } from './pipes/file-validation.pipe'; +export type { FileValidationOptions } from './pipes/file-validation.pipe'; diff --git a/apps/api/src/modules/shared/infrastructure/middleware/sanitize-input.middleware.ts b/apps/api/src/modules/shared/infrastructure/middleware/sanitize-input.middleware.ts new file mode 100644 index 0000000..563a64a --- /dev/null +++ b/apps/api/src/modules/shared/infrastructure/middleware/sanitize-input.middleware.ts @@ -0,0 +1,50 @@ +import { Injectable, type NestMiddleware } from '@nestjs/common'; +import type { NextFunction, Request, Response } from 'express'; +import sanitizeHtml from 'sanitize-html'; + +const SANITIZE_OPTIONS: sanitizeHtml.IOptions = { + allowedTags: [], + allowedAttributes: {}, + disallowedTagsMode: 'recursiveEscape', +}; + +function sanitizeValue(value: unknown): unknown { + if (typeof value === 'string') { + return sanitizeHtml(value, SANITIZE_OPTIONS); + } + if (Array.isArray(value)) { + return value.map(sanitizeValue); + } + if (value !== null && typeof value === 'object') { + return sanitizeObject(value as Record); + } + return value; +} + +function sanitizeObject(obj: Record): Record { + const sanitized: Record = {}; + for (const [key, val] of Object.entries(obj)) { + sanitized[key] = sanitizeValue(val); + } + return sanitized; +} + +/** + * Strips HTML tags from all string values in request body, query, and params + * to prevent stored XSS attacks. + */ +@Injectable() +export class SanitizeInputMiddleware implements NestMiddleware { + use(req: Request, _res: Response, next: NextFunction): void { + if (req.body && typeof req.body === 'object') { + req.body = sanitizeObject(req.body as Record); + } + if (req.query && typeof req.query === 'object') { + req.query = sanitizeObject(req.query as Record) as typeof req.query; + } + if (req.params && typeof req.params === 'object') { + req.params = sanitizeObject(req.params as Record) as typeof req.params; + } + next(); + } +} diff --git a/apps/api/src/modules/shared/infrastructure/pipes/file-validation.pipe.ts b/apps/api/src/modules/shared/infrastructure/pipes/file-validation.pipe.ts new file mode 100644 index 0000000..d62c9d8 --- /dev/null +++ b/apps/api/src/modules/shared/infrastructure/pipes/file-validation.pipe.ts @@ -0,0 +1,61 @@ +import { BadRequestException, Injectable, type PipeTransform } from '@nestjs/common'; + +export interface UploadedFile { + fieldname: string; + originalname: string; + encoding: string; + mimetype: string; + size: number; + buffer: Buffer; +} + +export interface FileValidationOptions { + /** Max file size in bytes. Default: 5 MB */ + maxSizeBytes?: number; + /** Allowed MIME types. Default: common image types + PDF */ + allowedMimeTypes?: string[]; +} + +const DEFAULT_MAX_SIZE = 5 * 1024 * 1024; // 5 MB +const DEFAULT_ALLOWED_MIMES = [ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/gif', + 'application/pdf', +]; + +/** + * Validates uploaded files for size and MIME type to prevent + * malicious file uploads and resource exhaustion. + */ +@Injectable() +export class FileValidationPipe implements PipeTransform { + private readonly maxSize: number; + private readonly allowedMimes: string[]; + + constructor(options?: FileValidationOptions) { + this.maxSize = options?.maxSizeBytes ?? DEFAULT_MAX_SIZE; + this.allowedMimes = options?.allowedMimeTypes ?? DEFAULT_ALLOWED_MIMES; + } + + transform(file: UploadedFile): UploadedFile { + if (!file) { + throw new BadRequestException('File is required'); + } + + if (file.size > this.maxSize) { + throw new BadRequestException( + `File size ${file.size} exceeds maximum allowed size of ${this.maxSize} bytes`, + ); + } + + if (!this.allowedMimes.includes(file.mimetype)) { + throw new BadRequestException( + `File type '${file.mimetype}' is not allowed. Allowed types: ${this.allowedMimes.join(', ')}`, + ); + } + + return file; + } +} diff --git a/apps/api/src/modules/shared/shared.module.ts b/apps/api/src/modules/shared/shared.module.ts index 10a9ef7..813d454 100644 --- a/apps/api/src/modules/shared/shared.module.ts +++ b/apps/api/src/modules/shared/shared.module.ts @@ -6,6 +6,7 @@ import { GlobalExceptionFilter } from './infrastructure/filters/global-exception import { LoggerService } from './infrastructure/logger.service'; import { CorrelationIdMiddleware } from './infrastructure/middleware/correlation-id.middleware'; import { RequestLoggingMiddleware } from './infrastructure/middleware/request-logging.middleware'; +import { SanitizeInputMiddleware } from './infrastructure/middleware/sanitize-input.middleware'; import { PrismaService } from './infrastructure/prisma.service'; import { RedisService } from './infrastructure/redis.service'; @@ -26,6 +27,8 @@ import { RedisService } from './infrastructure/redis.service'; }) export class SharedModule implements NestModule { configure(consumer: MiddlewareConsumer): void { - consumer.apply(CorrelationIdMiddleware, RequestLoggingMiddleware).forRoutes('*'); + consumer + .apply(CorrelationIdMiddleware, SanitizeInputMiddleware, RequestLoggingMiddleware) + .forRoutes('*'); } }