feat(security): add security hardening — Helmet, CORS, rate limiting, input sanitization

- 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 <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 02:04:13 +07:00
parent 5e44456d11
commit f3081d92fc
6 changed files with 197 additions and 1 deletions

View File

@@ -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');

View File

@@ -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<string> {
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');
}
}

View File

@@ -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';

View File

@@ -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<string, unknown>);
}
return value;
}
function sanitizeObject(obj: Record<string, unknown>): Record<string, unknown> {
const sanitized: Record<string, unknown> = {};
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<string, unknown>);
}
if (req.query && typeof req.query === 'object') {
req.query = sanitizeObject(req.query as Record<string, unknown>) as typeof req.query;
}
if (req.params && typeof req.params === 'object') {
req.params = sanitizeObject(req.params as Record<string, unknown>) as typeof req.params;
}
next();
}
}

View File

@@ -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;
}
}

View File

@@ -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('*');
}
}