From e933bce66c081eb05e3fab3e395889dd18de8936 Mon Sep 17 00:00:00 2001 From: ahmadogo Date: Tue, 24 Mar 2026 10:22:06 +0100 Subject: [PATCH] Missing Security Headers --- junit.xml | 664 ++++++++++-------- src/app.module.ts | 7 +- src/main.ts | 37 +- .../header-validation.middleware.ts | 308 ++++++++ src/security/security.module.ts | 19 +- .../header-validation.middleware.spec.ts | 314 +++++++++ 6 files changed, 1035 insertions(+), 314 deletions(-) create mode 100644 src/security/middleware/header-validation.middleware.ts create mode 100644 test/security/header-validation.middleware.spec.ts diff --git a/junit.xml b/junit.xml index a5884473..c723c1a7 100644 --- a/junit.xml +++ b/junit.xml @@ -1,241 +1,269 @@ - - - + + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + - + + + - - + + - + + + - + - + - + - + - + - + - + - + - + - + + + - + - + - + - + - + - + - + - + + + - + - + - + - + - + - + - + - + - + - + - + - - - + - - - + - + - + - + - + - + - + - + - - - + - + - + - + - + - + - + - + - + - + - - + + - + - + - + - - + + + + + + + + + + + + + + + + - + - + + + + + + + + + + + - - + + @@ -245,7 +273,7 @@ - + @@ -262,182 +290,176 @@ - - + + - + - + - - - - - - - - - + + + - + - + - + - + - - - + - + - + - + - + - + - + - + - + - - - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - - + + - + - + - + - + - + - + - + - + - + - + - + - - - + - + + + + + + + - + - + - + - + - + - + - + - + @@ -445,19 +467,19 @@ - + - + - + - + - + @@ -465,89 +487,119 @@ - + - + - - + + - + - + - + - + - - - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + + + + - + - + - + - + - + - - + + + + - + + + - + + + + + + + + + + + + + + + + + + + + + + + @@ -557,15 +609,15 @@ - + - + - + - + @@ -576,84 +628,96 @@ - - + + - + - + - + - + - + + + - + - + - + - + - - - + - + + + - + - + - + - + - - - + - + - + - + - + - - + + - + - + - + - + - + - + - + - - + + + + + + + + + + - + + + + + \ No newline at end of file diff --git a/src/app.module.ts b/src/app.module.ts index 8ad8f3d0..a57d9b2d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -49,6 +49,7 @@ import { AuditController } from './common/controllers/audit.controller'; // Middleware import { AuthRateLimitMiddleware } from './auth/middleware/auth.middleware'; +import { HeaderValidationMiddleware } from './security/middleware/header-validation.middleware'; import { ObservabilityModule } from './observability/observability.module'; @Module({ @@ -146,11 +147,11 @@ import { ObservabilityModule } from './observability/observability.module'; export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { consumer + // Header validation for security + .apply(HeaderValidationMiddleware) + .forRoutes('*') // Auth rate limiting .apply(AuthRateLimitMiddleware) .forRoutes('/auth*'); - // Global security middleware - // .apply(SecurityMiddleware) // Uncomment when SecurityModule is properly integrated - // .forRoutes('*'); } } diff --git a/src/main.ts b/src/main.ts index 2c3c3751..bb1a60f2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,6 +7,7 @@ import * as compression from 'compression'; import { AppModule } from './app.module'; import { StructuredLoggerService } from './common/logging/logger.service'; import { ErrorResponseDto } from './common/errors/error.dto'; +import { SecurityHeadersService } from './security/services/security-headers.service'; async function bootstrap() { const app = await NestFactory.create(AppModule, { @@ -21,15 +22,33 @@ async function bootstrap() { app.use(helmet()); app.use(compression()); - // Enhanced security headers - // const securityHeadersService = app.get(SecurityHeadersService); - // const securityHeaders = securityHeadersService.getSecurityHeaders(); - // Object.entries(securityHeaders).forEach(([key, value]) => { - // app.use((req, res, next) => { - // res.setHeader(key, value); - // next(); - // }); - // }); + // Enhanced security headers - CSP, HSTS, and other security headers + const securityHeadersService = app.get(SecurityHeadersService); + const isProduction = configService.get('NODE_ENV') === 'production'; + + // Get environment-specific security headers configuration + const securityConfig = isProduction + ? undefined // Use default production config + : securityHeadersService.getDevelopmentConfig(); + + // Validate configuration in production + if (isProduction) { + const configErrors = securityHeadersService.validateConfig(securityHeadersService['defaultConfig']); + if (configErrors.length > 0) { + logger.warn(`Security configuration warnings: ${configErrors.join(', ')}`); + } + } + + // Apply security headers middleware + const securityHeaders = securityHeadersService.getSecurityHeaders(securityConfig); + app.use((req: any, res: any, next: () => void) => { + Object.entries(securityHeaders).forEach(([key, value]) => { + res.setHeader(key, value); + }); + next(); + }); + + logger.log(`Security headers configured: ${Object.keys(securityHeaders).length} headers applied`); // CORS configuration app.enableCors({ diff --git a/src/security/middleware/header-validation.middleware.ts b/src/security/middleware/header-validation.middleware.ts new file mode 100644 index 00000000..26bacd91 --- /dev/null +++ b/src/security/middleware/header-validation.middleware.ts @@ -0,0 +1,308 @@ +import { Injectable, NestMiddleware, Logger, BadRequestException } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; + +export interface HeaderValidationResult { + isValid: boolean; + errors: string[]; + warnings: string[]; + sanitizedHeaders: Record; +} + +export interface HeaderValidationConfig { + maxHeaderSize: number; + maxHeaderCount: number; + blockSuspiciousPatterns: boolean; + allowedHeaders?: string[]; + blockedHeaders?: string[]; +} + +@Injectable() +export class HeaderValidationMiddleware implements NestMiddleware { + private readonly logger = new Logger(HeaderValidationMiddleware.name); + private readonly config: HeaderValidationConfig; + + // Patterns that indicate potential attacks + private readonly suspiciousPatterns = [ + /)<[^<]*)*<\/script>/gi, // XSS scripts + /javascript:/gi, + /on\w+\s*=/gi, // Event handlers like onclick= + /data:\s*text\/html/gi, + /vbscript:/gi, + /expression\s*\(/gi, + /union\s+select/gi, // SQL injection + /or\s+1\s*=\s*1/gi, + /;\s*drop\s+table/gi, + /;\s*delete\s+from/gi, + /;\s*insert\s+into/gi, + /\.\.\//g, // Path traversal + /%2e%2e%2f/gi, // Encoded path traversal + /%252e%252e%252f/gi, // Double encoded path traversal + ]; + + // Headers that should be blocked for security reasons + private readonly defaultBlockedHeaders = [ + 'x-forwarded-host', // Can be spoofed + 'x-original-url', // Can be used for SSRF + 'x-rewrite-url', // Can be used for SSRF + ]; + + // Headers that are required for certain operations + private readonly requiredHeadersForApi = ['content-type']; + + constructor() { + this.config = { + maxHeaderSize: 8192, // 8KB max per header + maxHeaderCount: 50, + blockSuspiciousPatterns: true, + blockedHeaders: this.defaultBlockedHeaders, + }; + } + + use(req: Request, res: Response, next: NextFunction): void { + const result = this.validateHeaders(req); + + if (!result.isValid) { + this.logger.warn(`Header validation failed: ${result.errors.join(', ')}`); + throw new BadRequestException({ + statusCode: 400, + message: 'Invalid request headers', + errors: result.errors, + }); + } + + if (result.warnings.length > 0) { + this.logger.debug(`Header validation warnings: ${result.warnings.join(', ')}`); + } + + // Attach sanitized headers to request + (req as any).sanitizedHeaders = result.sanitizedHeaders; + + next(); + } + + /** + * Validate all request headers + */ + validateHeaders(req: Request): HeaderValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + const sanitizedHeaders: Record = {}; + + const headers = req.headers; + + // Check header count + const headerCount = Object.keys(headers).length; + if (headerCount > this.config.maxHeaderCount) { + errors.push(`Too many headers: ${headerCount} (max: ${this.config.maxHeaderCount})`); + } + + // Validate each header + for (const [name, value] of Object.entries(headers)) { + const headerName = name.toLowerCase(); + + // Skip undefined values + if (value === undefined) { + continue; + } + + // Convert array values to string for validation + const headerValue = Array.isArray(value) ? value.join(', ') : String(value); + + // Check header size + if (headerValue.length > this.config.maxHeaderSize) { + errors.push(`Header '${headerName}' exceeds maximum size`); + continue; + } + + // Check for blocked headers + if (this.config.blockedHeaders?.includes(headerName)) { + errors.push(`Blocked header detected: '${headerName}'`); + continue; + } + + // Check for suspicious patterns + if (this.config.blockSuspiciousPatterns) { + const suspiciousCheck = this.detectSuspiciousPatterns(headerName, headerValue); + if (suspiciousCheck.detected) { + errors.push(`Suspicious pattern in header '${headerName}': ${suspiciousCheck.pattern}`); + continue; + } + } + + // Check for null bytes + if (headerValue.includes('\0') || headerName.includes('\0')) { + errors.push(`Null byte detected in header '${headerName}'`); + continue; + } + + // Check for control characters (except common ones) + if (this.containsControlCharacters(headerValue)) { + warnings.push(`Control characters detected in header '${headerName}'`); + } + + // Sanitize and store + sanitizedHeaders[headerName] = this.sanitizeHeaderValue(headerValue); + } + + // Validate Content-Type for POST/PUT/PATCH requests + if (['POST', 'PUT', 'PATCH'].includes(req.method)) { + const contentType = headers['content-type']; + if (!contentType) { + warnings.push('Missing Content-Type header for request with body'); + } else if (!this.isValidContentType(contentType)) { + errors.push(`Invalid Content-Type: ${contentType}`); + } + } + + return { + isValid: errors.length === 0, + errors, + warnings, + sanitizedHeaders, + }; + } + + /** + * Detect suspicious patterns in header value + */ + private detectSuspiciousPatterns(headerName: string, headerValue: string): { detected: boolean; pattern?: string } { + // Skip validation for certain headers that may contain legitimate complex values + const skipPatternCheck = ['authorization', 'cookie', 'x-api-key']; + if (skipPatternCheck.includes(headerName)) { + return { detected: false }; + } + + for (const pattern of this.suspiciousPatterns) { + if (pattern.test(headerValue)) { + return { detected: true, pattern: pattern.source }; + } + } + + return { detected: false }; + } + + /** + * Check if value contains control characters + */ + private containsControlCharacters(value: string): boolean { + // Check for control characters except tab, newline, carriage return + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if (code < 32 && code !== 9 && code !== 10 && code !== 13) { + return true; + } + } + return false; + } + + /** + * Sanitize header value by removing dangerous characters + */ + private sanitizeHeaderValue(value: string): string { + return value + .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') // Remove control characters + .replace(/\r\n/g, ' ') // Replace CRLF with space (header injection prevention) + .trim(); + } + + /** + * Validate Content-Type header + */ + private isValidContentType(contentType: string): boolean { + const validContentTypes = [ + 'application/json', + 'application/x-www-form-urlencoded', + 'multipart/form-data', + 'text/plain', + 'text/html', + 'application/xml', + 'text/xml', + ]; + + const baseContentType = contentType.split(';')[0].trim().toLowerCase(); + return validContentTypes.includes(baseContentType) || baseContentType.startsWith('application/'); + } + + /** + * Validate specific header by name + */ + validateHeader(name: string, value: string): { isValid: boolean; error?: string } { + const headerName = name.toLowerCase(); + + if (this.config.blockedHeaders?.includes(headerName)) { + return { isValid: false, error: `Header '${name}' is blocked` }; + } + + if (value.length > this.config.maxHeaderSize) { + return { isValid: false, error: `Header '${name}' exceeds maximum size` }; + } + + const suspiciousCheck = this.detectSuspiciousPatterns(headerName, value); + if (suspiciousCheck.detected) { + return { isValid: false, error: `Suspicious pattern detected: ${suspiciousCheck.pattern}` }; + } + + return { isValid: true }; + } + + /** + * Get security report for headers + */ + getSecurityReport(headers: Record): { + score: number; + issues: string[]; + recommendations: string[]; + } { + const issues: string[] = []; + const recommendations: string[] = []; + let score = 100; + + // Check for missing security headers in response + const securityHeaders = { + 'content-security-policy': 20, + 'strict-transport-security': 15, + 'x-frame-options': 10, + 'x-content-type-options': 10, + 'x-xss-protection': 10, + 'referrer-policy': 5, + 'permissions-policy': 5, + }; + + for (const [header, points] of Object.entries(securityHeaders)) { + if (!headers[header]) { + issues.push(`Missing security header: ${header}`); + score -= points; + } + } + + // Check for information disclosure + if (headers['server'] && headers['server'].length > 20) { + issues.push('Server header reveals detailed information'); + recommendations.push('Configure server header to hide version information'); + score -= 5; + } + + if (headers['x-powered-by']) { + issues.push('X-Powered-By header exposes technology stack'); + recommendations.push('Remove X-Powered-By header'); + score -= 5; + } + + // Check HSTS configuration + if (headers['strict-transport-security']) { + const hsts = headers['strict-transport-security']; + if (!hsts.includes('includeSubDomains')) { + recommendations.push('Add includeSubDomains to HSTS header'); + } + if (!hsts.includes('preload')) { + recommendations.push('Consider adding preload to HSTS header'); + } + } + + return { + score: Math.max(0, score), + issues, + recommendations, + }; + } +} diff --git a/src/security/security.module.ts b/src/security/security.module.ts index c5b65e3e..cd00606a 100644 --- a/src/security/security.module.ts +++ b/src/security/security.module.ts @@ -6,12 +6,27 @@ import { IpBlockingService } from './services/ip-blocking.service'; import { DdosProtectionService } from './services/ddos-protection.service'; import { ApiQuotaService } from './services/api-quota.service'; import { SecurityHeadersService } from './services/security-headers.service'; +import { HeaderValidationMiddleware } from './middleware/header-validation.middleware'; import { SecurityController } from './security.controller'; @Module({ imports: [ConfigModule, RedisModule], controllers: [SecurityController], - providers: [RateLimitingService, IpBlockingService, DdosProtectionService, ApiQuotaService, SecurityHeadersService], - exports: [RateLimitingService, IpBlockingService, DdosProtectionService, ApiQuotaService, SecurityHeadersService], + providers: [ + RateLimitingService, + IpBlockingService, + DdosProtectionService, + ApiQuotaService, + SecurityHeadersService, + HeaderValidationMiddleware, + ], + exports: [ + RateLimitingService, + IpBlockingService, + DdosProtectionService, + ApiQuotaService, + SecurityHeadersService, + HeaderValidationMiddleware, + ], }) export class SecurityModule {} diff --git a/test/security/header-validation.middleware.spec.ts b/test/security/header-validation.middleware.spec.ts new file mode 100644 index 00000000..7e4b9408 --- /dev/null +++ b/test/security/header-validation.middleware.spec.ts @@ -0,0 +1,314 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException } from '@nestjs/common'; +import { HeaderValidationMiddleware } from '../../src/security/middleware/header-validation.middleware'; + +describe('HeaderValidationMiddleware', () => { + let middleware: HeaderValidationMiddleware; + + const mockRequest = (headers: Record = {}, method = 'GET') => + ({ + headers, + method, + }) as any; + + const mockResponse = () => ({} as any); + + const mockNext = jest.fn(); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [HeaderValidationMiddleware], + }).compile(); + + middleware = module.get(HeaderValidationMiddleware); + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(middleware).toBeDefined(); + }); + + describe('use', () => { + it('should pass valid headers', () => { + const req = mockRequest({ + 'content-type': 'application/json', + authorization: 'Bearer token', + }); + const res = mockResponse(); + + middleware.use(req, res, mockNext); + + expect(mockNext).toHaveBeenCalled(); + expect(req.sanitizedHeaders).toBeDefined(); + }); + + it('should throw BadRequestException for blocked headers', () => { + const req = mockRequest({ + 'x-forwarded-host': 'malicious.com', + }); + const res = mockResponse(); + + expect(() => middleware.use(req, res, mockNext)).toThrow(BadRequestException); + }); + + it('should throw BadRequestException for XSS patterns', () => { + const req = mockRequest({ + 'x-custom-header': '', + }); + const res = mockResponse(); + + expect(() => middleware.use(req, res, mockNext)).toThrow(BadRequestException); + }); + + it('should throw BadRequestException for SQL injection patterns', () => { + const req = mockRequest({ + 'x-custom-header': "'; DROP TABLE users; --", + }); + const res = mockResponse(); + + expect(() => middleware.use(req, res, mockNext)).toThrow(BadRequestException); + }); + + it('should throw BadRequestException for null bytes', () => { + const req = mockRequest({ + 'x-custom-header': 'value\x00injection', + }); + const res = mockResponse(); + + expect(() => middleware.use(req, res, mockNext)).toThrow(BadRequestException); + }); + + it('should pass headers with authorization', () => { + const req = mockRequest({ + authorization: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test', + }); + const res = mockResponse(); + + middleware.use(req, res, mockNext); + + expect(mockNext).toHaveBeenCalled(); + }); + + it('should pass headers with API key', () => { + const req = mockRequest({ + 'x-api-key': 'propchain_live_abc123xyz', + }); + const res = mockResponse(); + + middleware.use(req, res, mockNext); + + expect(mockNext).toHaveBeenCalled(); + }); + }); + + describe('validateHeaders', () => { + it('should return valid for empty headers', () => { + const req = mockRequest({}); + const result = middleware.validateHeaders(req); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should return invalid for oversized header', () => { + const longValue = 'a'.repeat(10000); + const req = mockRequest({ 'x-long-header': longValue }); + const result = middleware.validateHeaders(req); + + expect(result.isValid).toBe(false); + expect(result.errors[0]).toContain('exceeds maximum size'); + }); + + it('should return invalid for too many headers', () => { + const headers: Record = {}; + for (let i = 0; i < 60; i++) { + headers[`x-header-${i}`] = `value-${i}`; + } + const req = mockRequest(headers); + const result = middleware.validateHeaders(req); + + expect(result.isValid).toBe(false); + expect(result.errors[0]).toContain('Too many headers'); + }); + + it('should return warning for control characters', () => { + const req = mockRequest({ 'x-custom': 'value\x01test' }); + const result = middleware.validateHeaders(req); + + expect(result.warnings.length).toBeGreaterThan(0); + }); + + it('should return warning for missing Content-Type on POST', () => { + const req = mockRequest({}, 'POST'); + const result = middleware.validateHeaders(req); + + expect(result.warnings).toContain('Missing Content-Type header for request with body'); + }); + + it('should return invalid for invalid Content-Type', () => { + const req = mockRequest({ 'content-type': 'invalid/type' }, 'POST'); + const result = middleware.validateHeaders(req); + + expect(result.isValid).toBe(false); + expect(result.errors[0]).toContain('Invalid Content-Type'); + }); + + it('should sanitize headers', () => { + const req = mockRequest({ 'x-custom': ' value ' }); + const result = middleware.validateHeaders(req); + + expect(result.sanitizedHeaders['x-custom']).toBe('value'); + }); + }); + + describe('validateHeader', () => { + it('should return valid for normal header', () => { + const result = middleware.validateHeader('x-custom', 'value'); + + expect(result.isValid).toBe(true); + }); + + it('should return invalid for blocked header', () => { + const result = middleware.validateHeader('x-forwarded-host', 'malicious.com'); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('blocked'); + }); + + it('should return invalid for oversized header', () => { + const longValue = 'a'.repeat(10000); + const result = middleware.validateHeader('x-custom', longValue); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('exceeds maximum size'); + }); + + it('should return invalid for path traversal', () => { + const result = middleware.validateHeader('x-custom', '../../../etc/passwd'); + + expect(result.isValid).toBe(false); + }); + }); + + describe('getSecurityReport', () => { + it('should return high score for complete security headers', () => { + const headers = { + 'content-security-policy': "default-src 'self'", + 'strict-transport-security': 'max-age=31536000; includeSubDomains; preload', + 'x-frame-options': 'DENY', + 'x-content-type-options': 'nosniff', + 'x-xss-protection': '1; mode=block', + 'referrer-policy': 'strict-origin-when-cross-origin', + 'permissions-policy': 'geolocation=()', + }; + + const report = middleware.getSecurityReport(headers); + + expect(report.score).toBe(100); + expect(report.issues).toHaveLength(0); + }); + + it('should return low score for missing security headers', () => { + const headers = { + server: 'Apache/2.4.41 (Ubuntu) PHP/7.4.3', + 'x-powered-by': 'Express', + }; + + const report = middleware.getSecurityReport(headers); + + expect(report.score).toBeLessThan(50); + expect(report.issues.length).toBeGreaterThan(0); + expect(report.recommendations.length).toBeGreaterThan(0); + }); + + it('should detect information disclosure', () => { + const headers = { + server: 'Apache/2.4.41 (Ubuntu) with detailed version info', + 'x-powered-by': 'Express', + }; + + const report = middleware.getSecurityReport(headers); + + expect(report.issues).toContain('Server header reveals detailed information'); + expect(report.issues).toContain('X-Powered-By header exposes technology stack'); + }); + + it('should recommend HSTS improvements', () => { + const headers = { + 'strict-transport-security': 'max-age=31536000', + }; + + const report = middleware.getSecurityReport(headers); + + expect(report.recommendations).toContain('Add includeSubDomains to HSTS header'); + expect(report.recommendations).toContain('Consider adding preload to HSTS header'); + }); + }); + + describe('detectSuspiciousPatterns', () => { + it('should detect XSS patterns', () => { + const result = (middleware as any).detectSuspiciousPatterns( + 'x-custom', + '', + ); + + expect(result.detected).toBe(true); + }); + + it('should detect javascript: protocol', () => { + const result = (middleware as any).detectSuspiciousPatterns( + 'x-custom', + 'javascript:alert(1)', + ); + + expect(result.detected).toBe(true); + }); + + it('should detect SQL injection patterns', () => { + const result = (middleware as any).detectSuspiciousPatterns( + 'x-custom', + "' OR 1=1 --", + ); + + expect(result.detected).toBe(true); + }); + + it('should skip validation for authorization header', () => { + const result = (middleware as any).detectSuspiciousPatterns( + 'authorization', + 'Bearer ', + ); + + expect(result.detected).toBe(false); + }); + + it('should skip validation for cookie header', () => { + const result = (middleware as any).detectSuspiciousPatterns( + 'cookie', + 'session=', + ); + + expect(result.detected).toBe(false); + }); + }); + + describe('isValidContentType', () => { + it('should return true for application/json', () => { + const result = (middleware as any).isValidContentType('application/json'); + + expect(result).toBe(true); + }); + + it('should return true for multipart/form-data', () => { + const result = (middleware as any).isValidContentType('multipart/form-data; boundary=----'); + + expect(result).toBe(true); + }); + + it('should return false for invalid content type', () => { + const result = (middleware as any).isValidContentType('invalid/type'); + + expect(result).toBe(false); + }); + }); +});