diff --git a/.env.development b/.env.development index cabc1eb0..5dce303a 100644 --- a/.env.development +++ b/.env.development @@ -3,6 +3,9 @@ NODE_ENV=development PORT=3000 HOST=localhost +# CORS - Allow localhost in development +CORS_ORIGIN=http://localhost:3000,http://localhost:5173,http://127.0.0.1:3000,http://127.0.0.1:5173 + # Database DATABASE_URL=postgresql://postgres:password@localhost:5432/propchain_dev diff --git a/.env.example b/.env.example index 4571848f..70ce8072 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,12 @@ NODE_ENV=development PORT=3000 HOST=0.0.0.0 API_PREFIX=api -CORS_ORIGIN=* +# CORS Configuration: +# - Development: Use specific localhost origins (e.g., http://localhost:3000,http://localhost:5173) +# - Production/Staging: Use specific production domains (e.g., https://propchain.io,https://app.propchain.io) +# - Wildcard '*' is only allowed in development/test environments +# - Multiple origins can be comma-separated: https://example.com,https://api.example.com +CORS_ORIGIN=http://localhost:3000,http://localhost:5173 SWAGGER_ENABLED=true # Database Configuration @@ -48,6 +53,17 @@ EMAIL_FROM=noreply@propchain.io COINGECKO_API_KEY=your-coingecko-api-key OPENSEA_API_KEY=your-opensea-api-key ZILLOW_API_KEY=your-zillow-api-key + +# File Upload Security Configuration +MAX_FILE_SIZE=10485760 +MAX_FILES_PER_UPLOAD=10 +ALLOWED_FILE_TYPES=image/jpeg,image/png,image/gif,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document +VALIDATE_MAGIC_NUMBERS=true +MALWARE_SCANNING_ENABLED=true +CLAMAV_HOST=localhost +CLAMAV_PORT=3310 +CLAMAV_TIMEOUT=30000 +MAX_SCAN_SIZE_BYTES=26214400 REDFIN_API_KEY=your-redfin-api-key CORE_LOGIC_API_KEY=your-core-logic-api-key MAXMIND_LICENSE_KEY=your-maxmind-license-key diff --git a/.env.production b/.env.production index fab181e8..dd9d8b69 100644 --- a/.env.production +++ b/.env.production @@ -3,6 +3,11 @@ NODE_ENV=production PORT=3000 HOST=0.0.0.0 +# CORS - REQUIRED: Specify production domains (comma-separated) +# Example: CORS_ORIGIN=https://propchain.io,https://app.propchain.io +# WARNING: Wildcard '*' is NOT allowed in production +CORS_ORIGIN=https://your-production-domain.com + # Database DATABASE_URL=postgresql://user:secure_password@prod-db:5432/propchain_prod diff --git a/.env.staging b/.env.staging index b5118675..3b011e59 100644 --- a/.env.staging +++ b/.env.staging @@ -3,6 +3,11 @@ NODE_ENV=staging PORT=3000 HOST=0.0.0.0 +# CORS - REQUIRED: Specify staging domains (comma-separated) +# Example: CORS_ORIGIN=https://staging.propchain.io,https://staging-app.propchain.io +# WARNING: Wildcard '*' is NOT allowed in staging +CORS_ORIGIN=https://your-staging-domain.com + # Database DATABASE_URL=postgresql://user:password@staging-db:5432/propchain_staging diff --git a/package.json b/package.json index 9235b3e9..5881b995 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,6 @@ "start:debug": "nest start --debug --watch", "start:staging": "cross-env NODE_ENV=staging node dist/main", "start:prod": "cross-env NODE_ENV=production node dist/main", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "test": "jest --config ./jest.config.js", "test:watch": "jest --config ./jest.config.js --watch", diff --git a/src/config/utils/cors-origin.validator.ts b/src/config/utils/cors-origin.validator.ts new file mode 100644 index 00000000..2a606c8f --- /dev/null +++ b/src/config/utils/cors-origin.validator.ts @@ -0,0 +1,129 @@ +/** + * CORS Origin Validator Utility + * Validates and parses CORS origins from configuration + */ +export class CorsOriginValidator { + /** + * Validate and parse CORS origins from environment variable + * Supports: + * - '*' (wildcard - only allowed in development/test) + * - Single origin: 'https://example.com' + * - Multiple origins: 'https://example.com,https://api.example.com' + * + * @param origin - The CORS_ORIGIN environment variable value + * @param nodeEnv - Current NODE_ENV + * @returns Array of allowed origins or false if invalid + */ + static validate(origin: string | undefined, nodeEnv: string): string[] | false { + // If not set, default based on environment + if (!origin || origin.trim() === '') { + if (nodeEnv === 'development' || nodeEnv === 'test') { + console.warn('[CorsOriginValidator] CORS_ORIGIN not set. Using default allow-all in development/test'); + return ['*']; + } + console.error('[CorsOriginValidator] CORS_ORIGIN is required in production/staging'); + return false; + } + + const trimmedOrigin = origin.trim(); + + // Handle wildcard + if (trimmedOrigin === '*') { + if (nodeEnv === 'production' || nodeEnv === 'staging') { + console.error('[CorsOriginValidator] Wildcard (*) CORS origin is not allowed in production/staging'); + return false; + } + console.warn('[CorsOriginValidator] CORS configured to allow all origins (development/test mode)'); + return ['*']; + } + + // Parse comma-separated origins + const origins = trimmedOrigin.split(',').map(o => o.trim()); + const validatedOrigins: string[] = []; + const urlPattern = /^https?:\/\/[^\s/$.?#].[^\/]*$/; + + for (const origin of origins) { + // Reject wildcard in list + if (origin === '*') { + console.error('[CorsOriginValidator] Wildcard (*) found in origin list - not allowed'); + return false; + } + + // Check for localhost in production/staging + if (origin.startsWith('http://localhost') || origin.startsWith('http://127.0.0.1')) { + if (nodeEnv === 'production' || nodeEnv === 'staging') { + console.error(`[CorsOriginValidator] Localhost origin "${origin}" is not allowed in production/staging`); + return false; + } + validatedOrigins.push(origin); + continue; + } + + // Validate URL format (basic check) + if (!urlPattern.test(origin)) { + console.error(`[CorsOriginValidator] Invalid origin format: "${origin}". Expected valid URL (e.g., https://example.com)`); + return false; + } + + validatedOrigins.push(origin); + } + + if (validatedOrigins.length === 0) { + console.error('[CorsOriginValidator] No valid CORS origins provided'); + return false; + } + + console.log(`[CorsOriginValidator] CORS origins validated: ${validatedOrigins.join(', ')}`); + return validatedOrigins; + } + + /** + * Parse CORS_ORIGIN string to array of origins + * Returns array suitable for NestJS CORS configuration + * + * @param origin - The CORS_ORIGIN value + * @returns Array of origins + */ + static parseForNestJs(origin: string | undefined): string[] { + if (!origin) { + return ['*']; + } + + if (origin.trim() === '*') { + return ['*']; + } + + return origin.split(',').map(o => o.trim()); + } + + /** + * Check if origin is in the allowed list + * Useful for custom CORS validation + * + * @param origin - The origin to check + * @param allowedOrigins - Array of allowed origins + * @returns true if origin is allowed + */ + static isOriginAllowed(origin: string, allowedOrigins: string[]): boolean { + // Wildcard allows all + if (allowedOrigins.includes('*')) { + return true; + } + + // Check exact match + if (allowedOrigins.includes(origin)) { + return true; + } + + // Check domain match (origin without trailing slash) + const normalizedOrigin = origin.endsWith('/') ? origin.slice(0, -1) : origin; + for (const allowed of allowedOrigins) { + const normalizedAllowed = allowed.endsWith('/') ? allowed.slice(0, -1) : allowed; + if (normalizedOrigin === normalizedAllowed) { + return true; + } + } + + return false; + } +} \ No newline at end of file diff --git a/src/config/validation/config.validation.ts b/src/config/validation/config.validation.ts index 650982d5..413c5eb4 100644 --- a/src/config/validation/config.validation.ts +++ b/src/config/validation/config.validation.ts @@ -1,5 +1,54 @@ import * as Joi from 'joi'; +/** + * Custom validation for CORS origins + * - '*' is only allowed in development/test environments + * - Production/staging must use specific domains + */ +const corsOriginValidation = (value: string, helpers: Joi.CustomHelpers) => { + const nodeEnv = Joi.ref('$NODE_ENV'); + + // If no value provided, error + if (!value || value.trim() === '') { + return helpers.error('cors.origin.required'); + } + + // Allow '*' only in development and test + if (value === '*') { + const env = helpers.prefs?.context?.NODE_ENV || 'development'; + if (env !== 'development' && env !== 'test') { + return helpers.error('cors.origin.wildcard.notAllowed'); + } + return value; + } + + // Validate individual origins (comma-separated) + const origins = value.split(',').map(o => o.trim()); + const urlPattern = /^https?:\/\/[\w.-]+(:\d+)?(\/.*)?$/; + + for (const origin of origins) { + if (origin === '*') { + return helpers.error('cors.origin.wildcard.notAllowed'); + } + + // Allow 'http://localhost' and 'http://localhost:*' variants + if (origin.startsWith('http://localhost') || origin.startsWith('http://127.0.0.1')) { + const env = helpers.prefs?.context?.NODE_ENV || 'development'; + if (env !== 'development' && env !== 'test') { + return helpers.error('cors.origin.localhost.notAllowed'); + } + continue; + } + + // Validate URL format + if (!urlPattern.test(origin)) { + return helpers.error('cors.origin.invalidFormat', { origin }); + } + } + + return value; +}; + /** * Joi validation schema for application configuration */ @@ -9,7 +58,12 @@ export const configValidationSchema = Joi.object({ PORT: Joi.number().default(3000), HOST: Joi.string().default('0.0.0.0'), API_PREFIX: Joi.string().default('api'), - CORS_ORIGIN: Joi.string().default('*'), + CORS_ORIGIN: Joi.string().custom(corsOriginValidation).default('*').messages({ + 'cors.origin.required': 'CORS_ORIGIN is required', + 'cors.origin.wildcard.notAllowed': 'Wildcard (*) origin is not allowed in production/staging. Specify explicit allowed origins.', + 'cors.origin.localhost.notAllowed': 'Localhost origins are not allowed in production/staging', + 'cors.origin.invalidFormat': 'Invalid origin format: "{{origin}}". Must be a valid URL (e.g., https://example.com)', + }), SWAGGER_ENABLED: Joi.boolean().default(true), // Database diff --git a/src/documents/document.controller.ts b/src/documents/document.controller.ts index 889dd64e..301e5527 100644 --- a/src/documents/document.controller.ts +++ b/src/documents/document.controller.ts @@ -11,8 +11,9 @@ import { UploadedFiles, UseInterceptors, } from '@nestjs/common'; -import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express'; import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiHeader, ApiConsumes } from '@nestjs/swagger'; +import { DocumentFilesUploadInterceptor, DocumentFileUploadInterceptor } from './interceptors/document-upload.interceptor'; +import { SecureFileValidator } from '../security/validators/secure-file.validator'; import { DocumentAccessContext, DocumentMetadataInput, DocumentSearchFilters } from './document.model'; import { DocumentService } from './document.service'; import { @@ -27,10 +28,13 @@ import { @ApiTags('documents') @Controller('documents') export class DocumentController { - constructor(private readonly documentService: DocumentService) {} + constructor( + private readonly documentService: DocumentService, + private readonly secureFileValidator: SecureFileValidator, + ) {} @Post('upload') - @UseInterceptors(FilesInterceptor('files')) + @UseInterceptors(DocumentFilesUploadInterceptor) @ApiOperation({ summary: 'Upload documents with metadata' }) @ApiConsumes('multipart/form-data') @ApiHeader({ name: 'x-user-id', description: 'User ID', required: true }) @@ -43,13 +47,18 @@ export class DocumentController { @Headers('x-user-id') userId: string, @Headers('x-user-roles') rolesHeader?: string, ) { + // Validate each file with comprehensive security checks + for (const file of files) { + await this.secureFileValidator.validate(file); + } + const context = this.buildAccessContext(userId, rolesHeader); const metadata = this.parseMetadataInput(body); return this.documentService.uploadDocuments(files, metadata, context); } @Post(':id/version') - @UseInterceptors(FileInterceptor('file')) + @UseInterceptors(DocumentFileUploadInterceptor) @ApiOperation({ summary: 'Add a new version to existing document' }) @ApiConsumes('multipart/form-data') @ApiParam({ name: 'id', description: 'Document ID' }) @@ -63,6 +72,9 @@ export class DocumentController { @Headers('x-user-id') userId: string, @Headers('x-user-roles') rolesHeader?: string, ) { + // Validate file with comprehensive security checks + await this.secureFileValidator.validate(file); + const context = this.buildAccessContext(userId, rolesHeader); return this.documentService.addDocumentVersion(documentId, file, context); } diff --git a/src/documents/document.service.ts b/src/documents/document.service.ts index 8641b2a6..663291b1 100644 --- a/src/documents/document.service.ts +++ b/src/documents/document.service.ts @@ -5,6 +5,7 @@ import axios from 'axios'; import sharp from 'sharp'; import { v4 as uuidv4 } from 'uuid'; import storageConfig, { StorageConfig } from '../config/storage.config'; +import { MalwareScannerService } from '../security/services/malware-scanner.service'; import { DocumentAccessContext, DocumentAccessLevel, @@ -187,6 +188,7 @@ export class DocumentService { @Inject(STORAGE_CONFIG) private readonly config: StorageConfig = storageConfig(), @Inject(STORAGE_PROVIDER) private readonly storageProvider: StorageProvider = new InMemoryStorageProvider(storageConfig()), + private readonly malwareScanner?: MalwareScannerService, ) {} async uploadDocuments( @@ -347,7 +349,7 @@ export class DocumentService { versionNumber = 1, ): Promise { await this.validateFile(file); - this.scanForVirus(file.buffer); + await this.scanForVirus(file.buffer, file.originalname); const checksum = DocumentService.hashBuffer(file.buffer); const storageKey = this.buildStorageKey(documentId, versionNumber, file.originalname); @@ -426,7 +428,19 @@ export class DocumentService { } } - private scanForVirus(buffer: Buffer): void { + private async scanForVirus(buffer: Buffer, filename?: string): Promise { + // Use the real MalwareScannerService if available + if (this.malwareScanner) { + const result = await this.malwareScanner.scanFile(buffer, filename); + if (!result.isClean) { + throw new BadRequestException( + `Malware detected in file: ${result.virusName || 'Unknown virus'}`, + ); + } + return; + } + + // Fallback to basic signature check if no malware scanner is available if (buffer.toString('utf8').includes(DocumentService.virusSignature)) { throw new BadRequestException('File failed virus scan'); } diff --git a/src/documents/documents.module.ts b/src/documents/documents.module.ts index d1a0d689..2fc199e8 100644 --- a/src/documents/documents.module.ts +++ b/src/documents/documents.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; import storageConfig from '../config/storage.config'; import { DocumentController } from './document.controller'; import { @@ -9,12 +10,21 @@ import { STORAGE_PROVIDER, } from './document.service'; import { FileStorageService } from './storage/file-storage.service'; +import { SecureFileValidator } from '../security/validators/secure-file.validator'; +import { FileValidationService } from '../security/services/file-validation.service'; +import { MalwareScannerService } from '../security/services/malware-scanner.service'; +import { DocumentFilesUploadInterceptor, DocumentFileUploadInterceptor } from './interceptors/document-upload.interceptor'; @Module({ controllers: [DocumentController], providers: [ DocumentService, FileStorageService, + SecureFileValidator, + FileValidationService, + MalwareScannerService, + DocumentFilesUploadInterceptor, + DocumentFileUploadInterceptor, { provide: STORAGE_CONFIG, useFactory: storageConfig, @@ -30,5 +40,6 @@ import { FileStorageService } from './storage/file-storage.service'; inject: [STORAGE_CONFIG], }, ], + imports: [ConfigModule], }) export class DocumentsModule {} diff --git a/src/documents/interceptors/document-upload.interceptor.ts b/src/documents/interceptors/document-upload.interceptor.ts new file mode 100644 index 00000000..61d8d85c --- /dev/null +++ b/src/documents/interceptors/document-upload.interceptor.ts @@ -0,0 +1,27 @@ +import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; +import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express'; +import { Observable } from 'rxjs'; +import { ConfigService } from '@nestjs/config'; +import { getMultipleFileUploadOptions, getSingleFileUploadOptions } from '../../security/config/multer.config'; + +@Injectable() +export class DocumentFilesUploadInterceptor implements NestInterceptor { + constructor(private readonly configService: ConfigService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable | Promise> { + const MulterInterceptorClass = FilesInterceptor('files', 10, getMultipleFileUploadOptions(this.configService)); + const multerInterceptor: NestInterceptor = new MulterInterceptorClass(); + return multerInterceptor.intercept(context, next); + } +} + +@Injectable() +export class DocumentFileUploadInterceptor implements NestInterceptor { + constructor(private readonly configService: ConfigService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable | Promise> { + const MulterInterceptorClass = FileInterceptor('file', getSingleFileUploadOptions(this.configService)); + const multerInterceptor: NestInterceptor = new MulterInterceptorClass(); + return multerInterceptor.intercept(context, next); + } +} diff --git a/src/main.ts b/src/main.ts index c879eee5..3b6c9f25 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,6 +9,7 @@ import { StructuredLoggerService } from './common/logging/logger.service'; import { ErrorResponseDto } from './common/errors/error.dto'; import { SecurityHeadersService } from './security/services/security-headers.service'; import { DEFAULT_API_VERSION } from './common/api-version'; +import { CorsOriginValidator } from './config/utils/cors-origin.validator'; async function bootstrap() { const app = await NestFactory.create(AppModule, { @@ -67,13 +68,30 @@ async function bootstrap() { logger.log(`Security headers configured: ${Object.keys(securityHeaders).length} headers applied`); - // CORS configuration + // CORS configuration with validation + const corsOrigin = configService.get('CORS_ORIGIN'); + const nodeEnv = configService.get('NODE_ENV', 'development'); + + // Validate CORS origins before configuring + const validatedOrigins = CorsOriginValidator.validate(corsOrigin, nodeEnv); + + if (!validatedOrigins) { + logger.error('Invalid CORS configuration detected. Application cannot start with insecure CORS settings.'); + if (nodeEnv === 'production' || nodeEnv === 'staging') { + process.exit(1); // Fail hard in production/staging + } + } + + const corsOrigins = CorsOriginValidator.parseForNestJs(corsOrigin); + app.enableCors({ - origin: configService.get('CORS_ORIGIN', '*'), + origin: corsOrigins, credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'x-correlation-id'], }); + + logger.log(`CORS configured with origins: ${corsOrigins.join(', ')}`); // Global pipes app.useGlobalPipes( diff --git a/src/security/README.md b/src/security/README.md deleted file mode 100644 index 310ca81a..00000000 --- a/src/security/README.md +++ /dev/null @@ -1,209 +0,0 @@ -# Security Implementation - -This document describes the comprehensive security features implemented in the PropChain backend. - -## Features Implemented - -### 1. Advanced Rate Limiting - -- **Redis-based rate limiting** with sliding window algorithm -- **Multiple rate limit tiers**: API, Auth, Expensive operations, User-based -- **Customizable configurations** via environment variables -- **Rate limit headers** in HTTP responses -- **Fail-open design** to prevent service disruption - -### 2. IP Blocking and Whitelisting - -- **Automatic IP blocking** after failed attempts -- **Manual IP blocking/unblocking** via API -- **IP whitelist** functionality -- **Configurable thresholds** and block durations -- **Real-time blocking checks** in middleware - -### 3. DDoS Protection - -- **Traffic monitoring** and anomaly detection -- **Automatic attack mitigation** -- **Multiple mitigation strategies**: IP blocking, rate limiting, challenges -- **Attack logging** and reporting -- **Configurable thresholds** and response actions - -### 4. API Quota Management - -- **Plan-based quotas**: Free, Basic, Pro, Enterprise -- **Daily and monthly usage tracking** -- **Automatic quota reset** schedules -- **Usage monitoring** with headers -- **Quota enforcement** in API key validation - -### 5. Security Headers - -- **Content Security Policy (CSP)** with customizable directives -- **HTTP Strict Transport Security (HSTS)** -- **X-Frame-Options**, **X-Content-Type-Options**, **X-XSS-Protection** -- **Referrer Policy** and **Permissions Policy** -- **Environment-specific configurations** - -### 6. Enhanced Authentication Security - -- **Enhanced API key guard** with quota and rate limit checking -- **Comprehensive validation** including expiration and active status -- **Usage tracking** and quota consumption -- **Detailed security headers** in responses - -## Configuration - -### Environment Variables - -```env -# Advanced Rate Limiting -RATE_LIMIT_API_PER_MINUTE=100 -RATE_LIMIT_AUTH_PER_MINUTE=5 -RATE_LIMIT_EXPENSIVE_PER_MINUTE=10 -RATE_LIMIT_USER_PER_HOUR=1000 - -# IP Blocking -MAX_FAILED_ATTEMPTS=5 -FAILED_ATTEMPT_WINDOW_MS=900000 -AUTO_BLOCK_DURATION_MS=3600000 - -# DDoS Protection -DDOS_THRESHOLD_PER_MINUTE=100 -DDOS_MITIGATION_ACTION=block_ip -DDOS_BLOCK_DURATION_MS=3600000 -DDOS_ATTACK_RETENTION_HOURS=168 - -# Security Headers -SECURITY_HEADERS_ENABLED=true -CSP_REPORT_URI= -HSTS_MAX_AGE=31536000 -HSTS_INCLUDE_SUBDOMAINS=true -HSTS_PRELOAD=true -``` - -## API Endpoints - -### Security Management - -- `GET /api/security/rate-limit/:key` - Get rate limit information -- `DELETE /api/security/rate-limit/:key` - Reset rate limit -- `GET /api/security/ip-blocks` - Get blocked IPs -- `POST /api/security/ip-blocks` - Block an IP -- `DELETE /api/security/ip-blocks/:ip` - Unblock an IP -- `GET /api/security/ip-whitelist` - Get whitelist -- `POST /api/security/ip-whitelist` - Add to whitelist -- `DELETE /api/security/ip-whitelist/:ip` - Remove from whitelist -- `GET /api/security/ddos/status` - Get DDoS protection status -- `GET /api/security/ddos/attacks` - Get recent attacks -- `POST /api/security/ddos/block-ip` - Manual IP blocking -- `GET /api/security/quotas` - Get all quotas -- `GET /api/security/quotas/:apiKeyId` - Get specific quota -- `POST /api/security/quotas` - Set quota -- `DELETE /api/security/quotas/:apiKeyId` - Remove quota -- `GET /api/security/quotas/plans/available` - Available plans -- `GET /api/security/headers` - Current headers configuration -- `GET /api/security/headers/validate` - Validate configuration - -## Usage Examples - -### Rate Limiting Decorator - -```typescript -import { RateLimit } from '../security/decorators/rate-limit.decorator'; -import { AdvancedRateLimitGuard } from '../security/guards/advanced-rate-limit.guard'; - -@Controller('api/expensive') -@UseGuards(AdvancedRateLimitGuard) -export class ExpensiveOperationsController { - @Post('operation') - @RateLimit({ - windowMs: 60000, // 1 minute - maxRequests: 10, // 10 requests per minute - keyPrefix: 'expensive_ops', - }) - async performExpensiveOperation() { - // Your expensive operation here - } -} -``` - -### Enhanced API Key Protection - -```typescript -import { EnhancedApiKeyGuard } from '../common/guards/api-key.guard'; - -@Controller('api/protected') -@UseGuards(EnhancedApiKeyGuard) -export class ProtectedController { - @Get('data') - async getData(@Request() req) { - // req.apiKey contains quota and usage information - console.log('Remaining quota:', req.apiKey.quota.currentDailyUsage); - return { data: 'protected data' }; - } -} -``` - -## Security Headers Applied - -The system automatically applies the following security headers: - -- `Content-Security-Policy` -- `Strict-Transport-Security` -- `X-Content-Type-Options: nosniff` -- `X-Frame-Options: DENY` -- `X-XSS-Protection: 1; mode=block` -- `Referrer-Policy: strict-origin-when-cross-origin` -- `Permissions-Policy` -- `X-Download-Options: noopen` -- `X-Permitted-Cross-Domain-Policies: none` -- `X-DNS-Prefetch-Control: off` - -## Rate Limit Headers - -When rate limiting is applied, the following headers are included: - -- `X-RateLimit-Limit` -- `X-RateLimit-Remaining` -- `X-RateLimit-Reset` -- `X-RateLimit-Window` -- `X-Quota-Daily-Limit` -- `X-Quota-Daily-Remaining` -- `X-Quota-Monthly-Limit` -- `X-Quota-Monthly-Remaining` - -## Monitoring and Logging - -All security events are logged with appropriate severity levels: - -- **WARN**: Rate limit exceeded, IP blocked -- **ERROR**: Security service failures -- **INFO**: Security operations, configuration changes - -## Fail-Safe Design - -The security system is designed with fail-open principles: - -- If Redis is unavailable, rate limiting is bypassed -- If security services fail, requests are allowed -- Critical business operations continue during security service outages - -## Testing - -Comprehensive tests are included for all security features: - -- Unit tests for each service -- Integration tests for combined functionality -- Performance tests for high-load scenarios -- Security tests for edge cases - -## Future Enhancements - -Planned improvements: - -- Machine learning-based anomaly detection -- Geographic IP blocking -- Request fingerprinting -- Advanced CAPTCHA integration -- Rate limit analytics dashboard -- Automated threat intelligence integration diff --git a/src/security/config/multer.config.ts b/src/security/config/multer.config.ts new file mode 100644 index 00000000..4e646a58 --- /dev/null +++ b/src/security/config/multer.config.ts @@ -0,0 +1,75 @@ +import { ConfigService } from '@nestjs/config'; + +/** + * Create secure Multer configuration with strict limits + */ +export function createMulterConfig(configService: ConfigService) { + const maxFileSize = configService.get('MAX_FILE_SIZE', 10 * 1024 * 1024); + const maxFiles = configService.get('MAX_FILES_PER_UPLOAD', 10); + const allowedMimeTypes = configService + .get('ALLOWED_FILE_TYPES', 'image/jpeg,image/png,application/pdf') + .split(','); + + return { + // File size limits + limits: { + fileSize: maxFileSize, + files: maxFiles, + // Additional limits for security + fieldNameSize: 100, // Max field name length + headerPairs: 2000, // Max number of header pairs + }, + + // File filter for basic MIME type checking + fileFilter: (req: any, file: Express.Multer.File, cb: any) => { + if (!allowedMimeTypes.includes(file.mimetype)) { + const error = new Error(`File type ${file.mimetype} not allowed`); + (error as any).code = 'UNSUPPORTED_MEDIA_TYPE'; + return cb(error, false); + } + + // Validate filename + if (!file.originalname || file.originalname.length === 0) { + const error = new Error('Invalid filename'); + (error as any).code = 'INVALID_FILENAME'; + return cb(error, false); + } + + // Check for dangerous extensions + const dangerousExtensions = [ + '.exe', '.dll', '.so', '.bat', '.cmd', '.sh', + '.php', '.asp', '.aspx', '.jsp', '.cgi', + '.pl', '.py', '.rb', '.msi', '.com', '.pif', + ]; + + const lowerName = file.originalname.toLowerCase(); + if (dangerousExtensions.some(ext => lowerName.endsWith(ext))) { + const error = new Error('Dangerous file type detected'); + (error as any).code = 'DANGEROUS_FILE_TYPE'; + return cb(error, false); + } + + cb(null, true); + }, + }; +} + +/** + * Multer options for single file upload + */ +export function getSingleFileUploadOptions(configService: ConfigService) { + return { + ...createMulterConfig(configService), + preservePath: false, // Security: don't preserve original path + }; +} + +/** + * Multer options for multiple file upload + */ +export function getMultipleFileUploadOptions(configService: ConfigService) { + return { + ...createMulterConfig(configService), + preservePath: false, + }; +} diff --git a/src/security/security.module.ts b/src/security/security.module.ts index b928ebd4..1c5b7d25 100644 --- a/src/security/security.module.ts +++ b/src/security/security.module.ts @@ -1,5 +1,5 @@ import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; +import { ConfigModule, ConfigService } from '@nestjs/config'; import { RedisModule } from '../common/services/redis.module'; import { RateLimitingService } from './services/rate-limiting.service'; import { IpBlockingService } from './services/ip-blocking.service'; @@ -7,6 +7,9 @@ import { DdosProtectionService } from './services/ddos-protection.service'; import { ApiQuotaService } from './services/api-quota.service'; import { SecurityHeadersService } from './services/security-headers.service'; import { InputSanitizationService } from './services/input-sanitization.service'; +import { FileValidationService } from './services/file-validation.service'; +import { MalwareScannerService } from './services/malware-scanner.service'; +import { SecureFileValidator } from './validators/secure-file.validator'; import { HeaderValidationMiddleware } from './middleware/header-validation.middleware'; import { SecurityController } from './security.controller'; import { AdvancedRateLimitGuard } from './guards/advanced-rate-limit.guard'; @@ -22,6 +25,9 @@ import { SensitiveEndpointRateLimitGuard } from './guards/sensitive-endpoint-rat ApiQuotaService, SecurityHeadersService, InputSanitizationService, + FileValidationService, + MalwareScannerService, + SecureFileValidator, HeaderValidationMiddleware, AdvancedRateLimitGuard, SensitiveEndpointRateLimitGuard, @@ -33,6 +39,9 @@ import { SensitiveEndpointRateLimitGuard } from './guards/sensitive-endpoint-rat ApiQuotaService, SecurityHeadersService, InputSanitizationService, + FileValidationService, + MalwareScannerService, + SecureFileValidator, HeaderValidationMiddleware, AdvancedRateLimitGuard, SensitiveEndpointRateLimitGuard, diff --git a/src/security/services/cors-validation.service.ts b/src/security/services/cors-validation.service.ts new file mode 100644 index 00000000..8c173c93 --- /dev/null +++ b/src/security/services/cors-validation.service.ts @@ -0,0 +1,306 @@ +import { Injectable, Logger, BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export interface CorsOriginConfig { + allowedOrigins: Array; + allowCredentials: boolean; + allowedMethods: string[]; + allowedHeaders: string[]; + exposedHeaders?: string[]; + maxAge?: number; +} + +/** + * CORS validation service for secure cross-origin request management + * Provides environment-specific origin validation and dynamic origin checking + */ +@Injectable() +export class CorsValidationService { + private readonly logger = new Logger(CorsValidationService.name); + private readonly allowedOrigins: Set; + private readonly isProduction: boolean; + private readonly isDevelopment: boolean; + private readonly isTest: boolean; + + constructor(private configService: ConfigService) { + this.isProduction = this.configService.get('NODE_ENV') === 'production'; + this.isDevelopment = this.configService.get('NODE_ENV') === 'development'; + this.isTest = this.configService.get('NODE_ENV') === 'test'; + + // Parse and validate allowed origins + const originsConfig = this.configService.get('CORS_ALLOWED_ORIGINS', ''); + this.allowedOrigins = this.parseAllowedOrigins(originsConfig); + + // Log configuration on startup + this.logConfiguration(); + } + + /** + * Get CORS configuration based on environment + */ + getCorsConfig(): CorsOriginConfig { + if (this.isProduction) { + return this.getProductionCorsConfig(); + } else if (this.isTest) { + return this.getTestCorsConfig(); + } else { + return this.getDevelopmentCorsConfig(); + } + } + + /** + * Validate if an origin is allowed + */ + isOriginAllowed(origin: string): boolean { + if (!origin) { + return false; + } + + // In production, strictly validate against allowlist + if (this.isProduction) { + return this.allowedOrigins.has(origin); + } + + // In development/test, be more permissive but still validate + if (this.isDevelopment || this.isTest) { + // Allow localhost variations in development + if (origin.startsWith('http://localhost') || origin.startsWith('http://127.0.0.1')) { + return true; + } + + // Also check allowlist + return this.allowedOrigins.has(origin); + } + + return false; + } + + /** + * Dynamic origin validator for NestJS CORS + * Returns true if origin should be allowed, false otherwise + */ + getOriginValidator(): (origin: string) => boolean { + return (origin: string) => { + const allowed = this.isOriginAllowed(origin); + + if (!allowed && origin) { + this.logger.warn(`Blocked CORS request from unauthorized origin: ${origin}`); + } + + return allowed; + }; + } + + /** + * Validate CORS configuration for security issues + */ + validateConfig(): { isValid: boolean; errors: string[] } { + const errors: string[] = []; + + // Production must have explicit origins configured + if (this.isProduction) { + if (this.allowedOrigins.size === 0) { + errors.push('CORS_ALLOWED_ORIGINS must be configured in production'); + } + + // Check for wildcard in production + if (this.allowedOrigins.has('*')) { + errors.push('Wildcard (*) CORS origin is not allowed in production'); + } + + // Validate each origin URL format + for (const origin of this.allowedOrigins) { + if (!this.isValidOriginUrl(origin)) { + errors.push(`Invalid origin URL format: ${origin}`); + } + + // Warn about insecure origins in production + if (origin.startsWith('http://') && !origin.includes('localhost')) { + this.logger.warn( + `Insecure HTTP origin detected in production: ${origin}. Consider using HTTPS.`, + ); + } + } + } + + // Development warnings + if (this.isDevelopment) { + if (this.allowedOrigins.has('*')) { + this.logger.warn( + 'CORS wildcard (*) is enabled in development. This is acceptable for local development but should be disabled in production.', + ); + } + } + + return { + isValid: errors.length === 0, + errors, + }; + } + + /** + * Get statistics about CORS configuration + */ + getStats(): { + totalOrigins: number; + isProduction: boolean; + isWildcard: boolean; + hasLocalhost: boolean; + } { + return { + totalOrigins: this.allowedOrigins.size, + isProduction: this.isProduction, + isWildcard: this.allowedOrigins.has('*'), + hasLocalhost: Array.from(this.allowedOrigins).some(o => + o.includes('localhost') || o.includes('127.0.0.1'), + ), + }; + } + + /** + * Production CORS configuration - strict security + */ + private getProductionCorsConfig(): CorsOriginConfig { + const validation = this.validateConfig(); + + if (!validation.isValid) { + this.logger.error( + 'Production CORS configuration is invalid:', + validation.errors.join(', '), + {}, + ); + throw new BadRequestException( + `Invalid CORS configuration: ${validation.errors.join(', ')}`, + ); + } + + return { + allowedOrigins: Array.from(this.allowedOrigins), + allowCredentials: this.configService.get('CORS_CREDENTIALS_ENABLED', true), + allowedMethods: this.configService.get('CORS_ALLOWED_METHODS', [ + 'GET', + 'POST', + 'PUT', + 'DELETE', + 'PATCH', + 'OPTIONS', + ]), + allowedHeaders: this.configService.get('CORS_ALLOWED_HEADERS', [ + 'Content-Type', + 'Authorization', + 'X-Requested-With', + 'x-correlation-id', + 'Accept-Version', + ]), + exposedHeaders: this.configService.get('CORS_EXPOSED_HEADERS', [ + 'x-correlation-id', + 'x-request-id', + ]), + maxAge: this.configService.get('CORS_MAX_AGE', 86400), // 24 hours + }; + } + + /** + * Development CORS configuration - permissive for local testing + */ + private getDevelopmentCorsConfig(): CorsOriginConfig { + // If specific origins are configured, use them + if (this.allowedOrigins.size > 0 && !this.allowedOrigins.has('*')) { + return { + allowedOrigins: Array.from(this.allowedOrigins), + allowCredentials: true, + allowedMethods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'x-correlation-id'], + maxAge: 3600, // 1 hour + }; + } + + // Otherwise, use permissive development config + return { + allowedOrigins: [/^http:\/\/localhost:\d+$/, /^http:\/\/127\.0\.0\.1:\d+$/], + allowCredentials: true, + allowedMethods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'x-correlation-id'], + maxAge: 3600, + }; + } + + /** + * Test CORS configuration - minimal restrictions + */ + private getTestCorsConfig(): CorsOriginConfig { + return { + allowedOrigins: ['*'], + allowCredentials: false, + allowedMethods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'], + maxAge: 3600, + }; + } + + /** + * Parse allowed origins from configuration string + */ + private parseAllowedOrigins(originsConfig: string): Set { + const origins = new Set(); + + if (!originsConfig || originsConfig.trim() === '') { + return origins; + } + + // Split by comma and trim whitespace + const originList = originsConfig.split(',').map(o => o.trim()); + + for (const origin of originList) { + if (origin && origin !== '*') { + // Remove trailing slashes + const normalizedOrigin = origin.replace(/\/$/, ''); + origins.add(normalizedOrigin); + } else if (origin === '*' && !this.isProduction) { + // Only allow wildcard in non-production + origins.add('*'); + } + } + + return origins; + } + + /** + * Validate origin URL format + */ + private isValidOriginUrl(origin: string): boolean { + try { + const url = new URL(origin); + // Must be http or https + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } + } + + /** + * Log CORS configuration on startup + */ + private logConfiguration(): void { + const stats = this.getStats(); + + if (this.isProduction) { + this.logger.log( + `๐Ÿ”’ Production CORS configured with ${stats.totalOrigins} allowed origin(s)`, + ); + if (stats.totalOrigins > 0) { + this.logger.debug(`Allowed origins: ${Array.from(this.allowedOrigins).join(', ')}`); + } + } else if (this.isDevelopment) { + if (stats.isWildcard) { + this.logger.warn('โš ๏ธ Development CORS: Wildcard (*) enabled - OK for local development'); + } else { + this.logger.log( + `๐Ÿ”ง Development CORS configured with ${stats.totalOrigins} allowed origin(s)`, + ); + } + } else { + this.logger.log(`๐Ÿงช Test CORS configured`); + } + } +} diff --git a/src/security/services/file-validation.service.ts b/src/security/services/file-validation.service.ts new file mode 100644 index 00000000..d11e78b4 --- /dev/null +++ b/src/security/services/file-validation.service.ts @@ -0,0 +1,294 @@ +import { Injectable, BadRequestException, Logger } from '@nestjs/common'; +import * as crypto from 'crypto'; + +export interface FileTypeMatch { + ext: string; + mime: string; +} + +export interface FileValidationResult { + isValid: boolean; + fileType?: FileTypeMatch; + checksum?: string; + errors?: string[]; +} + +/** + * Comprehensive file validation service using magic number detection + * Prevents MIME type spoofing attacks by validating actual file content + */ +@Injectable() +export class FileValidationService { + private readonly logger = new Logger(FileValidationService.name); + + // Magic numbers for common file types (first bytes of files) + private readonly magicNumberSignatures: Record = { + image: [ + { offset: 0, bytes: 'FFD8FF', mime: 'image/jpeg', ext: 'jpg' }, + { offset: 0, bytes: '89504E470D0A1A0A', mime: 'image/png', ext: 'png' }, + { offset: 0, bytes: '474946383761', mime: 'image/gif', ext: 'gif' }, + { offset: 0, bytes: '474946383961', mime: 'image/gif', ext: 'gif' }, + { offset: 0, bytes: '52494646', mime: 'image/webp', ext: 'webp' }, // RIFF....WEBP + { offset: 8, bytes: '57454250', mime: 'image/webp', ext: 'webp' }, // WEBP at offset 8 + { offset: 0, bytes: '0000000C6A502020', mime: 'image/jp2', ext: 'jp2' }, + { offset: 0, bytes: '424D', mime: 'image/bmp', ext: 'bmp' }, + { offset: 0, bytes: '00000100', mime: 'image/x-icon', ext: 'ico' }, + ], + document: [ + { offset: 0, bytes: '255044462D', mime: 'application/pdf', ext: 'pdf' }, // %PDF- + { offset: 0, bytes: 'D0CF11E0A1B11AE1', mime: 'application/msword', ext: 'doc' }, + { offset: 0, bytes: '504B0304', mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', ext: 'docx' }, + { offset: 0, bytes: '504B0304', mime: 'application/vnd.ms-excel', ext: 'xls' }, + { offset: 0, bytes: '504B0304', mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ext: 'xlsx' }, + { offset: 0, bytes: '504B0304', mime: 'application/vnd.ms-powerpoint', ext: 'ppt' }, + { offset: 0, bytes: '504B0304', mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', ext: 'pptx' }, + ], + archive: [ + { offset: 0, bytes: '504B0304', mime: 'application/zip', ext: 'zip' }, + { offset: 0, bytes: '1F8B08', mime: 'application/gzip', ext: 'gz' }, + { offset: 0, bytes: 'FD377A585A00', mime: 'application/x-xz', ext: 'xz' }, + { offset: 0, bytes: '7A627879', mime: 'application/x-bzip2', ext: 'bz2' }, + { offset: 0, bytes: '52617221', mime: 'application/vnd.rar', ext: 'rar' }, + { offset: 0, bytes: '377ABCAF271C', mime: 'application/x-7z-compressed', ext: '7z' }, + ], + video: [ + { offset: 0, bytes: '000000186674797069736F6D', mime: 'video/mp4', ext: 'mp4' }, + { offset: 0, bytes: '0000001C6674797069736F6D', mime: 'video/mp4', ext: 'mp4' }, + { offset: 4, bytes: '6674797069736F6D', mime: 'video/mp4', ext: 'mp4' }, + { offset: 0, bytes: '1A45DFA3', mime: 'video/webm', ext: 'webm' }, + { offset: 0, bytes: '0000002066747970', mime: 'video/quicktime', ext: 'mov' }, + ], + audio: [ + { offset: 0, bytes: '494433', mime: 'audio/mpeg', ext: 'mp3' }, // ID3 + { offset: 0, bytes: 'FFF', mime: 'audio/mpeg', ext: 'mp3' }, + { offset: 0, bytes: '664C6143', mime: 'audio/flac', ext: 'flac' }, // fLaC + { offset: 0, bytes: '52494646', mime: 'audio/wav', ext: 'wav' }, // RIFF....WAVE + { offset: 8, bytes: '57415645', mime: 'audio/wav', ext: 'wav' }, + { offset: 0, bytes: '4F676753', mime: 'audio/ogg', ext: 'ogg' }, // OggS + ], + }; + + // Dangerous file extensions that should be blocked + private readonly dangerousExtensions = [ + '.exe', '.dll', '.so', '.dylib', // Executables + '.bat', '.cmd', '.sh', '.bash', '.ps1', '.vbs', '.js', '.jar', // Scripts + '.php', '.asp', '.aspx', '.jsp', '.cgi', // Web scripts + '.pl', '.py', '.rb', '.swf', // Other executables + '.msi', '.com', '.pif', '.scr', '.reg', // Windows executables + '.lnk', '.inf', '.drv', // System files + '.htaccess', '.htpasswd', // Apache config + ]; + + /** + * Validate a file buffer using magic number detection + */ + validateFile(buffer: Buffer, declaredMimeType?: string): FileValidationResult { + const errors: string[] = []; + + if (!buffer || buffer.length === 0) { + return { + isValid: false, + errors: ['Empty file provided'], + }; + } + + // Detect actual file type using magic numbers + const detectedType = this.detectFileType(buffer); + + if (!detectedType) { + return { + isValid: false, + errors: ['Unable to determine file type. File may be corrupted or unsupported.'], + }; + } + + // Check for MIME type mismatch (potential spoofing attack) + if (declaredMimeType && declaredMimeType !== detectedType.mime) { + errors.push( + `MIME type mismatch detected. Declared: ${declaredMimeType}, Actual: ${detectedType.mime}`, + ); + } + + // Calculate checksum + const checksum = this.calculateChecksum(buffer); + + if (errors.length > 0) { + return { + isValid: false, + fileType: detectedType, + checksum, + errors, + }; + } + + return { + isValid: true, + fileType: detectedType, + checksum, + }; + } + + /** + * Check if file extension is dangerous + */ + isDangerousExtension(filename: string): boolean { + const lowerFilename = filename.toLowerCase(); + return this.dangerousExtensions.some(ext => + lowerFilename.endsWith(ext) || lowerFilename.includes(ext + '.') + ); + } + + /** + * Validate filename for security issues + */ + validateFilename(filename: string): void { + if (!filename || filename.trim().length === 0) { + throw new BadRequestException('Invalid filename'); + } + + // Check for path traversal attempts + if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) { + throw new BadRequestException('Invalid filename: path traversal detected'); + } + + // Check for null bytes + if (filename.includes('\0')) { + throw new BadRequestException('Invalid filename: null byte detected'); + } + + // Check for dangerous extensions + if (this.isDangerousExtension(filename)) { + throw new BadRequestException(`File type not allowed: ${filename}`); + } + + // Check filename length (max 255 characters) + if (filename.length > 255) { + throw new BadRequestException('Filename too long (max 255 characters)'); + } + } + + /** + * Detect file type using magic number signatures + */ + private detectFileType(buffer: Buffer): FileTypeMatch | null { + const hexHeader = buffer.toString('hex', 0, Math.min(16, buffer.length)).toUpperCase(); + + // Check all signature categories + for (const category of Object.values(this.magicNumberSignatures)) { + for (const signature of category) { + const startByte = signature.offset * 2; // Convert bytes to hex chars + const endByte = startByte + signature.bytes.length; + const fileHeader = hexHeader.substring(startByte, endByte); + + if (fileHeader.startsWith(signature.bytes)) { + // Additional check for formats with longer signatures + if (signature.bytes.length > 8) { + return { ext: signature.ext, mime: signature.mime }; + } + + // For common signatures like PK (504B0304), do additional validation + if (signature.bytes === '504B0304') { + // ZIP-based formats - check further to distinguish + return this.distinguishZipFormat(buffer, signature); + } + + return { ext: signature.ext, mime: signature.mime }; + } + } + } + + // Fallback: check for text-based formats + if (this.isTextFile(buffer)) { + return { ext: 'txt', mime: 'text/plain' }; + } + + return null; + } + + /** + * Distinguish between ZIP-based formats (docx, xlsx, pptx, zip, etc.) + */ + private distinguishZipFormat(buffer: Buffer, baseSignature: any): FileTypeMatch { + try { + // Look for mimetype markers in the ZIP structure + const content = buffer.toString('binary'); + + if (content.includes('[Content_Types].xml')) { + if (content.includes('word/')) { + return { ext: 'docx', mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }; + } + if (content.includes('xl/')) { + return { ext: 'xlsx', mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }; + } + if (content.includes('ppt/')) { + return { ext: 'pptx', mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' }; + } + } + } catch (error) { + // Ignore errors, fallback to generic ZIP + } + + return { ext: 'zip', mime: 'application/zip' }; + } + + /** + * Check if buffer represents a text file + */ + private isTextFile(buffer: Buffer): boolean { + try { + const text = buffer.toString('utf8').toLowerCase(); + // Check for common text patterns and absence of binary data + const hasBinaryNulls = buffer.indexOf(0, 10) !== -1; + const hasReadableContent = /[a-zA-Z0-9]/.test(text); + return !hasBinaryNulls && hasReadableContent; + } catch { + return false; + } + } + + /** + * Calculate SHA-256 checksum of buffer + */ + private calculateChecksum(buffer: Buffer): string { + return crypto.createHash('sha256').update(buffer).digest('hex'); + } + + /** + * Get supported MIME types for a category + */ + getSupportedMimeTypes(category: 'image' | 'document' | 'archive' | 'video' | 'audio' | 'all'): string[] { + if (category === 'all') { + return Object.values(this.magicNumberSignatures) + .flat() + .map(sig => sig.mime); + } + + const signatures = this.magicNumberSignatures[category] || []; + return signatures.map(sig => sig.mime); + } + + /** + * Validate against allowed MIME types list + */ + isMimeTypeAllowed(mimeType: string, allowedTypes: string[]): boolean { + if (!mimeType || !allowedTypes?.length) { + return false; + } + + // Exact match + if (allowedTypes.includes(mimeType)) { + return true; + } + + // Wildcard match (e.g., image/*) + const wildcardMatches = allowedTypes.some(allowed => { + if (allowed.endsWith('/*')) { + const prefix = allowed.slice(0, -2); + return mimeType.startsWith(prefix + '/'); + } + return false; + }); + + return wildcardMatches; + } +} diff --git a/src/security/services/malware-scanner.service.ts b/src/security/services/malware-scanner.service.ts new file mode 100644 index 00000000..96c95ae5 --- /dev/null +++ b/src/security/services/malware-scanner.service.ts @@ -0,0 +1,276 @@ +import { Injectable, Logger, BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as net from 'net'; + +export interface ScanResult { + isClean: boolean; + virusName?: string; + scanTime: number; + scanner: 'clamav' | 'basic' | 'none'; +} + +/** + * Malware scanning service with ClamAV integration + * Provides enterprise-grade virus scanning for uploaded files + */ +@Injectable() +export class MalwareScannerService { + private readonly logger = new Logger(MalwareScannerService.name); + private readonly clamavHost: string; + private readonly clamavPort: number; + private readonly clamavTimeout: number; + private readonly maxScanSizeBytes: number; + private readonly isEnabled: boolean; + + constructor(private configService: ConfigService) { + this.clamavHost = this.configService.get('CLAMAV_HOST', 'localhost'); + this.clamavPort = this.configService.get('CLAMAV_PORT', 3310); + this.clamavTimeout = this.configService.get('CLAMAV_TIMEOUT', 30000); + this.maxScanSizeBytes = this.configService.get('MAX_SCAN_SIZE_BYTES', 25 * 1024 * 1024); // 25MB + this.isEnabled = this.configService.get('MALWARE_SCANNING_ENABLED', true); + } + + /** + * Scan file buffer for malware + */ + async scanFile(buffer: Buffer, filename?: string): Promise { + const startTime = Date.now(); + + try { + if (!this.isEnabled) { + this.logger.warn('Malware scanning is disabled, skipping scan'); + return this.createResult(true, undefined, startTime, 'none'); + } + + // Check file size before scanning + if (buffer.length > this.maxScanSizeBytes) { + throw new BadRequestException( + `File too large for malware scanning (max ${this.maxScanSizeBytes / 1024 / 1024}MB)`, + ); + } + + // Attempt ClamAV scan + try { + const result = await this.scanWithClamAV(buffer); + return this.createResult(result.isClean, result.virusName, startTime, 'clamav'); + } catch (clamavError) { + this.logger.warn(`ClamAV scan failed: ${clamavError.message}. Falling back to basic scan.`); + + // Fallback to basic scanning + return this.basicScan(buffer, filename); + } + } catch (error) { + this.logger.error(`Malware scan failed for ${filename}: ${error.message}`); + throw new BadRequestException('Failed to scan file for malware'); + } + } + + /** + * Scan using ClamAV daemon via TCP socket + */ + private scanWithClamAV(buffer: Buffer): Promise<{ isClean: boolean; virusName?: string }> { + return new Promise((resolve, reject) => { + const client = new net.Socket(); + let scanned = false; + + const timeout = setTimeout(() => { + if (!scanned) { + client.destroy(); + reject(new Error('ClamAV scan timeout')); + } + }, this.clamavTimeout); + + client.on('connect', () => { + this.logger.debug('Connected to ClamAV daemon'); + + // Send INSTREAM command + client.write('nINSTREAM\0'); + + // Send file size as 4 bytes (big-endian) + const sizeBuffer = Buffer.alloc(4); + sizeBuffer.writeUInt32BE(buffer.length, 0); + client.write(sizeBuffer); + + // Send file data + client.write(buffer); + + // Send end marker (0 bytes) + const endBuffer = Buffer.alloc(4); + endBuffer.writeUInt32BE(0, 0); + client.write(endBuffer); + }); + + client.on('data', (data: Buffer) => { + if (scanned) return; + scanned = true; + clearTimeout(timeout); + + const response = data.toString().trim(); + this.logger.debug(`ClamAV response: ${response}`); + + // Parse response: stream: OK or stream: VirusName FOUND + if (response.endsWith('OK')) { + resolve({ isClean: true }); + } else if (response.includes('FOUND')) { + const virusName = response.split(': ')[1]?.replace(' FOUND', ''); + this.logger.warn(`Malware detected: ${virusName}`); + resolve({ isClean: false, virusName }); + } else { + reject(new Error(`Unexpected ClamAV response: ${response}`)); + } + + client.destroy(); + }); + + client.on('error', (error) => { + if (!scanned) { + clearTimeout(timeout); + reject(new Error(`ClamAV connection error: ${error.message}`)); + } + }); + + client.on('close', () => { + if (!scanned) { + clearTimeout(timeout); + reject(new Error('ClamAV connection closed unexpectedly')); + } + }); + + client.connect(this.clamavPort, this.clamavHost); + }); + } + + /** + * Basic malware scanning fallback (signature-based) + * Detects common malware patterns when ClamAV is unavailable + */ + private basicScan(buffer: Buffer, filename?: string): ScanResult { + const startTime = Date.now(); + + // Known malware signatures (simplified examples) + const suspiciousPatterns = [ + // EICAR test file (safe test signature) + /X5O!P%@AP\[4\\PZX54\(P\^\)7CC\)7\}\$EICAR/, + + // Common malware indicators + /mz\s*[\x00-\xff]{0,256}(stub|payload|shellcode)/i, + / 7.5 && buffer.length > 1024) { + this.logger.warn(`High entropy (${entropy.toFixed(2)}) detected in ${filename || 'file'}`); + // Don't block, just warn + } + + return this.createResult(true, undefined, startTime, 'basic'); + } + + /** + * Calculate Shannon entropy of buffer + * High entropy (>7.5) may indicate encryption/packing + */ + private calculateEntropy(buffer: Buffer): number { + const frequency = new Array(256).fill(0); + + for (const byte of buffer) { + frequency[byte]++; + } + + let entropy = 0; + const len = buffer.length; + + for (const count of frequency) { + if (count > 0) { + const p = count / len; + entropy -= p * Math.log2(p); + } + } + + return entropy; + } + + /** + * Create standardized scan result + */ + private createResult( + isClean: boolean, + virusName: string | undefined, + startTime: number, + scanner: 'clamav' | 'basic' | 'none', + ): ScanResult { + return { + isClean, + virusName, + scanTime: Date.now() - startTime, + scanner, + }; + } + + /** + * Check if ClamAV is available and responding + */ + async healthCheck(): Promise<{ available: boolean; version?: string }> { + return new Promise((resolve) => { + const client = new net.Socket(); + const timeout = setTimeout(() => { + client.destroy(); + resolve({ available: false }); + }, 5000); + + client.on('connect', () => { + client.write('nVERSION\0'); + }); + + client.on('data', (data: Buffer) => { + clearTimeout(timeout); + const version = data.toString().trim(); + client.destroy(); + resolve({ available: true, version }); + }); + + client.on('error', () => { + clearTimeout(timeout); + resolve({ available: false }); + }); + + client.connect(this.clamavPort, this.clamavHost); + }); + } + + /** + * Get scanner statistics + */ + getStats(): { + isEnabled: boolean; + scanner: string; + host: string; + port: number; + maxSize: number; + } { + return { + isEnabled: this.isEnabled, + scanner: this.isEnabled ? 'clamav' : 'disabled', + host: this.clamavHost, + port: this.clamavPort, + maxSize: this.maxScanSizeBytes, + }; + } +} diff --git a/src/security/validators/secure-file.validator.ts b/src/security/validators/secure-file.validator.ts new file mode 100644 index 00000000..b99daca5 --- /dev/null +++ b/src/security/validators/secure-file.validator.ts @@ -0,0 +1,116 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { FileValidationService } from '../services/file-validation.service'; +import { MalwareScannerService } from '../services/malware-scanner.service'; + +export interface SecureFileUploadConfig { + maxFileSize: number; + allowedMimeTypes: string[]; + maxFiles: number; + scanForMalware: boolean; + validateMagicNumbers: boolean; +} + +/** + * Custom file validator for secure file uploads + * Integrates with FileValidationService and MalwareScannerService + */ +@Injectable() +export class SecureFileValidator { + constructor( + private configService: ConfigService, + private fileValidationService: FileValidationService, + private malwareScannerService: MalwareScannerService, + ) {} + + /** + * Validate uploaded file with comprehensive security checks + */ + async validate(file: Express.Multer.File): Promise { + const config = this.getUploadConfig(); + + // 1. Check file size + if (file.size > config.maxFileSize) { + throw new BadRequestException( + `File size (${this.formatBytes(file.size)}) exceeds maximum allowed size (${this.formatBytes(config.maxFileSize)})`, + ); + } + + // 2. Validate filename + this.fileValidationService.validateFilename(file.originalname); + + // 3. Validate file type using magic numbers (if enabled) + if (config.validateMagicNumbers) { + const validationResult = this.fileValidationService.validateFile( + file.buffer, + file.mimetype, + ); + + if (!validationResult.isValid) { + throw new BadRequestException( + `File validation failed: ${validationResult.errors?.join(', ') || 'Unknown file type'}`, + ); + } + + // Check if detected MIME type is allowed + if ( + validationResult.fileType && + !this.fileValidationService.isMimeTypeAllowed( + validationResult.fileType.mime, + config.allowedMimeTypes, + ) + ) { + throw new BadRequestException( + `File type '${validationResult.fileType.mime}' is not allowed`, + ); + } + } else { + // Fallback to basic MIME type check + if (!config.allowedMimeTypes.includes(file.mimetype)) { + throw new BadRequestException( + `File type '${file.mimetype}' is not allowed. Allowed types: ${config.allowedMimeTypes.join(', ')}`, + ); + } + } + + // 4. Scan for malware (if enabled) + if (config.scanForMalware) { + const scanResult = await this.malwareScannerService.scanFile( + file.buffer, + file.originalname, + ); + + if (!scanResult.isClean) { + throw new BadRequestException( + `Security alert: File contains malware - ${scanResult.virusName || 'Unknown virus'}`, + ); + } + } + } + + /** + * Get upload configuration from environment + */ + getUploadConfig(): SecureFileUploadConfig { + return { + maxFileSize: this.configService.get('MAX_FILE_SIZE', 10 * 1024 * 1024), // 10MB default + allowedMimeTypes: this.configService + .get('ALLOWED_FILE_TYPES', 'image/jpeg,image/png,application/pdf') + .split(','), + maxFiles: this.configService.get('MAX_FILES_PER_UPLOAD', 10), + scanForMalware: this.configService.get('MALWARE_SCANNING_ENABLED', true), + validateMagicNumbers: this.configService.get('VALIDATE_MAGIC_NUMBERS', true), + }; + } + + /** + * Format bytes to human-readable string + */ + private formatBytes(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; + } +} diff --git a/src/users/user.controller.ts b/src/users/user.controller.ts index 8a774b7a..86521cda 100644 --- a/src/users/user.controller.ts +++ b/src/users/user.controller.ts @@ -4,10 +4,6 @@ import { CreateUserDto } from './dto/create-user.dto'; import { ApiTags, ApiOperation, - ApiResponse, - ApiBody, - ApiBearerAuth, - ApiParam, ApiExtraModels, ApiOkResponse, ApiBadRequestResponse, diff --git a/test/documents/document.controller.spec.ts b/test/documents/document.controller.spec.ts index 05dc5823..2de5d336 100644 --- a/test/documents/document.controller.spec.ts +++ b/test/documents/document.controller.spec.ts @@ -1,6 +1,8 @@ import { DocumentController } from '../../src/documents/document.controller'; import { DocumentAccessLevel, DocumentType } from '../../src/documents/document.model'; import { DocumentService } from '../../src/documents/document.service'; +import { SecureFileValidator } from '../../src/security/validators/secure-file.validator'; +import { ConfigService } from '@nestjs/config'; describe('DocumentController', () => { const createMockFile = (): Express.Multer.File => ({ @@ -20,7 +22,19 @@ describe('DocumentController', () => { const service: Partial = { uploadDocuments: jest.fn().mockResolvedValue([{ id: 'doc-1' }]), }; - const controller = new DocumentController(service as DocumentService); + const secureFileValidator: Partial = { + validate: jest.fn().mockResolvedValue(undefined), + }; + const configService: Partial = { + get: jest.fn().mockImplementation((key: string, defaultValue?: any) => { + return defaultValue; + }), + }; + + const controller = new DocumentController( + service as DocumentService, + secureFileValidator as SecureFileValidator, + ); await controller.uploadDocuments( [createMockFile()], diff --git a/test/documents/document.service.spec.ts b/test/documents/document.service.spec.ts index d3848e7f..aeeca22f 100644 --- a/test/documents/document.service.spec.ts +++ b/test/documents/document.service.spec.ts @@ -10,6 +10,7 @@ import { STORAGE_CONFIG, STORAGE_PROVIDER, } from '../../src/documents/document.service'; +import { MalwareScannerService } from '../../src/security/services/malware-scanner.service'; const createMockFile = ( buffer: Buffer, @@ -61,6 +62,20 @@ describe('DocumentService', () => { DocumentService, { provide: STORAGE_CONFIG, useValue: config }, { provide: STORAGE_PROVIDER, useValue: storageProvider }, + { + provide: MalwareScannerService, + useValue: { + scanFile: jest.fn().mockImplementation((buffer: Buffer) => { + const content = buffer.toString('utf8'); + if ( + content.includes('X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*') + ) { + return Promise.resolve({ isClean: false, virusName: 'EICAR', scanTime: 1, scanner: 'basic' }); + } + return Promise.resolve({ isClean: true, scanTime: 1, scanner: 'none' }); + }), + }, + } ], }).compile(); @@ -138,7 +153,7 @@ describe('DocumentService', () => { { title: 'Malicious', type: DocumentType.OTHER }, { userId: 'user-3', roles: [] }, ), - ).rejects.toThrow('File failed virus scan'); + ).rejects.toThrow(/Malware detected in file/); }); it('returns signed download URLs', async () => { diff --git a/test/security/file-validation.service.spec.ts b/test/security/file-validation.service.spec.ts new file mode 100644 index 00000000..403561d2 --- /dev/null +++ b/test/security/file-validation.service.spec.ts @@ -0,0 +1,165 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { BadRequestException } from '@nestjs/common'; +import { FileValidationService } from '../../src/security/services/file-validation.service'; + +describe('FileValidationService', () => { + let service: FileValidationService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [FileValidationService], + }).compile(); + + service = module.get(FileValidationService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('validateFile', () => { + it('should reject empty files', () => { + const emptyBuffer = Buffer.alloc(0); + const result = service.validateFile(emptyBuffer); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual(expect.stringContaining('Empty file')); + }); + + it('should detect JPEG image using magic numbers', () => { + // JPEG magic bytes: FFD8FF + const jpegBuffer = Buffer.from([0xFF, 0xD8, 0xFF, 0xE0, ...Array(100).fill(0)]); + const result = service.validateFile(jpegBuffer, 'image/jpeg'); + + expect(result.isValid).toBe(true); + expect(result.fileType).toEqual({ ext: 'jpg', mime: 'image/jpeg' }); + expect(result.checksum).toBeDefined(); + }); + + it('should detect PNG image using magic numbers', () => { + // PNG magic bytes: 89504E470D0A1A0A + const pngBuffer = Buffer.from([ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + ...Array(100).fill(0), + ]); + const result = service.validateFile(pngBuffer, 'image/png'); + + expect(result.isValid).toBe(true); + expect(result.fileType).toEqual({ ext: 'png', mime: 'image/png' }); + }); + + it('should detect PDF document using magic numbers', () => { + // PDF magic bytes: 255044462D (starts with %PDF-) + const pdfBuffer = Buffer.from([ + 0x25, 0x50, 0x44, 0x46, 0x2D, + ...Array(100).fill(0), + ]); + const result = service.validateFile(pdfBuffer, 'application/pdf'); + + expect(result.isValid).toBe(true); + expect(result.fileType).toEqual({ ext: 'pdf', mime: 'application/pdf' }); + }); + + it('should detect MIME type mismatch', () => { + // Create JPEG buffer but claim it's PNG + const jpegBuffer = Buffer.from([0xFF, 0xD8, 0xFF, 0xE0, ...Array(100).fill(0)]); + const result = service.validateFile(jpegBuffer, 'image/png'); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual( + expect.stringContaining('MIME type mismatch'), + ); + }); + + it('should calculate checksum correctly', () => { + const buffer = Buffer.from('test content'); + const result = service.validateFile(buffer); + + expect(result.checksum).toBeDefined(); + expect(result.checksum!.length).toBe(64); // SHA-256 produces 64 hex chars + }); + }); + + describe('validateFilename', () => { + it('should accept valid filenames', () => { + expect(() => service.validateFilename('document.pdf')).not.toThrow(); + expect(() => service.validateFilename('image.jpg')).not.toThrow(); + }); + + it('should reject path traversal attempts', () => { + expect(() => service.validateFilename('../etc/passwd')).toThrow(BadRequestException); + expect(() => service.validateFilename('..\\..\\windows\\system32')).toThrow(BadRequestException); + expect(() => service.validateFilename('/etc/shadow')).toThrow(BadRequestException); + }); + + it('should reject dangerous file extensions', () => { + expect(() => service.validateFilename('malware.exe')).toThrow(BadRequestException); + expect(() => service.validateFilename('script.bat')).toThrow(BadRequestException); + expect(() => service.validateFilename('hack.php')).toThrow(BadRequestException); + }); + + it('should reject filenames with null bytes', () => { + expect(() => service.validateFilename('file\0.txt')).toThrow(BadRequestException); + }); + + it('should reject very long filenames', () => { + const longName = 'a'.repeat(256) + '.txt'; + expect(() => service.validateFilename(longName)).toThrow(BadRequestException); + }); + }); + + describe('isDangerousExtension', () => { + it('should identify dangerous extensions', () => { + expect(service.isDangerousExtension('file.exe')).toBe(true); + expect(service.isDangerousExtension('script.php')).toBe(true); + expect(service.isDangerousExtension('hack.asp')).toBe(true); + }); + + it('should allow safe extensions', () => { + expect(service.isDangerousExtension('document.pdf')).toBe(false); + expect(service.isDangerousExtension('image.jpg')).toBe(false); + expect(service.isDangerousExtension('file.txt')).toBe(false); + }); + }); + + describe('getSupportedMimeTypes', () => { + it('should return all supported MIME types for "all" category', () => { + const mimeTypes = service.getSupportedMimeTypes('all'); + expect(mimeTypes.length).toBeGreaterThan(10); + expect(mimeTypes).toContain('image/jpeg'); + expect(mimeTypes).toContain('application/pdf'); + }); + + it('should return image MIME types', () => { + const mimeTypes = service.getSupportedMimeTypes('image'); + expect(mimeTypes).toContain('image/jpeg'); + expect(mimeTypes).toContain('image/png'); + }); + + it('should return document MIME types', () => { + const mimeTypes = service.getSupportedMimeTypes('document'); + expect(mimeTypes).toContain('application/pdf'); + }); + }); + + describe('isMimeTypeAllowed', () => { + it('should allow exact MIME type matches', () => { + const allowed = ['image/jpeg', 'image/png', 'application/pdf']; + expect(service.isMimeTypeAllowed('image/jpeg', allowed)).toBe(true); + expect(service.isMimeTypeAllowed('application/pdf', allowed)).toBe(true); + }); + + it('should reject non-matching MIME types', () => { + const allowed = ['image/jpeg', 'image/png']; + expect(service.isMimeTypeAllowed('application/pdf', allowed)).toBe(false); + }); + + it('should support wildcard matching', () => { + const allowed = ['image/*', 'application/pdf']; + expect(service.isMimeTypeAllowed('image/gif', allowed)).toBe(true); + expect(service.isMimeTypeAllowed('image/png', allowed)).toBe(true); + expect(service.isMimeTypeAllowed('application/pdf', allowed)).toBe(true); + }); + }); +});