From 6db275c05385d467e8f840fbfaa780f58cbdf243 Mon Sep 17 00:00:00 2001 From: kamaldeen Aliyu Date: Fri, 20 Feb 2026 12:05:42 +0100 Subject: [PATCH 1/4] Implemented input Validation and Sanitization --- package-lock.json | 42 ++- package.json | 5 +- src/common/controllers/audit.controller.ts | 2 +- .../request-size-limiter.middleware.ts | 60 ++++ .../validators/file-upload.validator.ts | 300 ++++++++++++++++++ .../validators/sql-injection.validator.ts | 115 +++++++ src/common/validators/xss.validator.ts | 75 +++++ src/properties/dto/create-property.dto.ts | 18 ++ src/users/dto/create-user.dto.ts | 12 + 9 files changed, 625 insertions(+), 4 deletions(-) create mode 100644 src/common/middleware/request-size-limiter.middleware.ts create mode 100644 src/common/validators/file-upload.validator.ts create mode 100644 src/common/validators/sql-injection.validator.ts create mode 100644 src/common/validators/xss.validator.ts diff --git a/package-lock.json b/package-lock.json index 35fd312a..e6b91394 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,7 +64,8 @@ "uuid": "^9.0.1", "web3": "^4.3.0", "winston": "^3.11.0", - "winston-daily-rotate-file": "^4.7.1" + "winston-daily-rotate-file": "^4.7.1", + "xss": "^1.0.15" }, "devDependencies": { "@commitlint/cli": "^18.4.3", @@ -77,6 +78,7 @@ "@types/cors": "^2.8.17", "@types/crypto-js": "^4.2.1", "@types/express": "^4.17.21", + "@types/file-type": "^10.6.0", "@types/jest": "^29.5.14", "@types/jsonwebtoken": "^9.0.5", "@types/lodash": "^4.14.202", @@ -4831,6 +4833,16 @@ "@types/send": "*" } }, + "node_modules/@types/file-type": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/@types/file-type/-/file-type-10.6.0.tgz", + "integrity": "sha512-qn06Cjx7HZDMmDIqMn+smKr7JYQMVXoHqwMC2vdg19CxGpL0Q6KwuvnVEkws2sH5wqrWXExmvuztXtWSHs3MBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -7808,6 +7820,12 @@ "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", "license": "MIT" }, + "node_modules/cssfilter": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz", + "integrity": "sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==", + "license": "MIT" + }, "node_modules/csv-parser": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/csv-parser/-/csv-parser-3.2.0.tgz", @@ -18675,6 +18693,28 @@ } } }, + "node_modules/xss": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz", + "integrity": "sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==", + "license": "MIT", + "dependencies": { + "commander": "^2.20.3", + "cssfilter": "0.0.10" + }, + "bin": { + "xss": "bin/xss" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/xss/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 302432e9..3e21388e 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,8 @@ "uuid": "^9.0.1", "web3": "^4.3.0", "winston": "^3.11.0", - "winston-daily-rotate-file": "^4.7.1" + "winston-daily-rotate-file": "^4.7.1", + "xss": "^1.0.15" }, "devDependencies": { "@commitlint/cli": "^18.4.3", @@ -111,6 +112,7 @@ "@types/cors": "^2.8.17", "@types/crypto-js": "^4.2.1", "@types/express": "^4.17.21", + "@types/file-type": "^10.6.0", "@types/jest": "^29.5.14", "@types/jsonwebtoken": "^9.0.5", "@types/lodash": "^4.14.202", @@ -140,7 +142,6 @@ "tsconfig-paths": "^4.2.0", "typescript": "^5.3.2" }, - "engines": { "node": ">=18.0.0", "npm": ">=8.0.0" diff --git a/src/common/controllers/audit.controller.ts b/src/common/controllers/audit.controller.ts index 8b83882a..118451b8 100644 --- a/src/common/controllers/audit.controller.ts +++ b/src/common/controllers/audit.controller.ts @@ -27,7 +27,7 @@ import { Action } from '../../rbac/enums/action.enum'; import { RequireScopes } from '../decorators/require-scopes.decorator'; import { AuditInterceptor } from '../interceptors/audit.interceptor'; -// Audit controller + @ApiTags('Audit & Compliance') @Controller('audit') @UseGuards(JwtAuthGuard, RbacGuard) diff --git a/src/common/middleware/request-size-limiter.middleware.ts b/src/common/middleware/request-size-limiter.middleware.ts new file mode 100644 index 00000000..0a80739f --- /dev/null +++ b/src/common/middleware/request-size-limiter.middleware.ts @@ -0,0 +1,60 @@ +import { Injectable, NestMiddleware, BadRequestException } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; + +@Injectable() +export class RequestSizeLimiterMiddleware implements NestMiddleware { + private readonly maxRequestSize: number; + private readonly maxUrlLength: number; + private readonly maxHeadersCount: number; + + constructor() { + // Get values from config or use defaults + this.maxRequestSize = parseInt(process.env.MAX_REQUEST_SIZE || '10485760', 10); // 10MB default + this.maxUrlLength = parseInt(process.env.MAX_URL_LENGTH || '2048', 10); // 2KB default + this.maxHeadersCount = parseInt(process.env.MAX_HEADERS_COUNT || '50', 10); // 50 headers default + } + + use(req: Request, res: Response, next: NextFunction) { + // Check URL length + if (req.url.length > this.maxUrlLength) { + throw new BadRequestException( + `Request URL exceeds maximum allowed length of ${this.maxUrlLength} characters` + ); + } + + // Check number of headers + const headerCount = Object.keys(req.headers).length; + if (headerCount > this.maxHeadersCount) { + throw new BadRequestException( + `Request exceeds maximum allowed headers count of ${this.maxHeadersCount}` + ); + } + + // Check content length header if present + const contentLength = req.headers['content-length']; + if (contentLength) { + const size = parseInt(contentLength, 10); + if (size > this.maxRequestSize) { + throw new BadRequestException( + `Request body exceeds maximum allowed size of ${this.maxRequestSize} bytes` + ); + } + } + + // For chunked requests or when content-length is not available, monitor stream size + let receivedSize = 0; + if (req.method === 'POST' || req.method === 'PUT' || req.method === 'PATCH') { + req.on('data', (chunk: Buffer) => { + receivedSize += chunk.length; + if (receivedSize > this.maxRequestSize) { + req.destroy(); + throw new BadRequestException( + `Request body exceeds maximum allowed size of ${this.maxRequestSize} bytes` + ); + } + }); + } + + next(); + } +} \ No newline at end of file diff --git a/src/common/validators/file-upload.validator.ts b/src/common/validators/file-upload.validator.ts new file mode 100644 index 00000000..084be937 --- /dev/null +++ b/src/common/validators/file-upload.validator.ts @@ -0,0 +1,300 @@ +import { registerDecorator, ValidationOptions, ValidatorConstraint, ValidatorConstraintInterface } from 'class-validator'; +import { HttpStatus } from '@nestjs/common'; +import * as fs from 'fs'; +import * as path from 'path'; +import { fileTypeFromBuffer } from 'file-type'; + +// Common file types for documents and images +const ALLOWED_MIME_TYPES = [ + // Images + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/gif', + 'image/svg+xml', + 'image/tiff', + 'image/bmp', + 'image/x-icon', + + // Documents + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'text/plain', + 'text/csv', + 'application/json', + 'application/xml', + 'application/zip', + 'application/x-rar-compressed', + 'application/x-tar', + 'application/gzip', + 'application/x-7z-compressed', +]; + +const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + +/** + * Custom validator constraint for file upload validation + */ +@ValidatorConstraint({ async: true }) +export class FileUploadValidatorConstraint implements ValidatorConstraintInterface { + async validate(file: Express.Multer.File) { + if (!file) { + return false; + } + + // Check file size + if (file.size > MAX_FILE_SIZE) { + return false; + } + + // Check MIME type + const detectedType = await fileTypeFromBuffer(file.buffer); + if (!detectedType || !ALLOWED_MIME_TYPES.includes(detectedType.mime)) { + return false; + } + + // Check file extension + const fileExtension = path.extname(file.originalname).toLowerCase(); + const validExtensions = this.getAllowedExtensions(); + if (!validExtensions.some(ext => ext === fileExtension)) { + return false; + } + + // Additional security checks for potential malicious content + if (await this.containsMaliciousContent(file.buffer)) { + return false; + } + + return true; + } + + private getAllowedExtensions(): string[] { + const extensionsMap: { [key: string]: string[] } = { + 'image/jpeg': ['.jpg', '.jpeg'], + 'image/png': ['.png'], + 'image/webp': ['.webp'], + 'image/gif': ['.gif'], + 'image/svg+xml': ['.svg'], + 'image/tiff': ['.tiff', '.tif'], + 'image/bmp': ['.bmp'], + 'image/x-icon': ['.ico'], + 'application/pdf': ['.pdf'], + 'application/msword': ['.doc'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], + 'application/vnd.ms-excel': ['.xls'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], + 'application/vnd.ms-powerpoint': ['.ppt'], + 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'], + 'text/plain': ['.txt'], + 'text/csv': ['.csv'], + 'application/json': ['.json'], + 'application/xml': ['.xml'], + 'application/zip': ['.zip'], + 'application/x-rar-compressed': ['.rar'], + 'application/x-tar': ['.tar'], + 'application/gzip': ['.gz'], + 'application/x-7z-compressed': ['.7z'], + }; + + const extensions: string[] = []; + ALLOWED_MIME_TYPES.forEach(mimeType => { + if (extensionsMap[mimeType]) { + extensions.push(...extensionsMap[mimeType]); + } + }); + + return extensions; + } + + private async containsMaliciousContent(buffer: Buffer): Promise { + // Check for common malicious patterns in the file content + const maliciousPatterns = [ + /)<[^<]*)*<\/script>/gi, // JavaScript in HTML + /)<[^<]*)*<\/iframe>/gi, // iframe in HTML + /)<[^<]*)*<\/object>/gi, // object in HTML + /)<[^<]*)*<\/embed>/gi, // embed in HTML + /]+rel=["']stylesheet["'][^>]*href=["']javascript:/gi, // JS in link href + /]+http-equiv=["']refresh["'][^>]*content=["']\d+;url=javascript:/gi, // JS in meta refresh + /vbscript:/gi, // VBScript protocol + /javascript:/gi, // JavaScript protocol + /data:text\/html/gi, // Data URI with HTML + /eval\s*\(/gi, // eval function + /expression\s*\(/gi, // expression function (IE) + /onload\s*=/gi, // onload event + /onerror\s*=/gi, // onerror event + /onclick\s*=/gi, // onclick event + /onmouseover\s*=/gi, // onmouseover event + /onfocus\s*=/gi, // onfocus event + /onblur\s*=/gi, // onblur event + ]; + + const content = buffer.toString('utf-8').toLowerCase(); + + for (const pattern of maliciousPatterns) { + if (pattern.test(content)) { + return true; + } + } + + return false; + } + + defaultMessage() { + return 'File upload validation failed: invalid file type, size, or contains malicious content'; + } +} + +/** + * Decorator to validate uploaded files + */ +export function IsValidFileUpload(validationOptions?: ValidationOptions) { + return function (object: Object, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + constraints: [], + validator: FileUploadValidatorConstraint, + }); + }; +} + +/** + * Function to validate file upload with custom parameters + */ +export async function validateFileUpload( + file: Express.Multer.File, + allowedMimeTypes: string[] = ALLOWED_MIME_TYPES, + maxSize: number = MAX_FILE_SIZE +): Promise<{ isValid: boolean; errors: string[] }> { + const errors: string[] = []; + + if (!file) { + errors.push('File is required'); + return { isValid: false, errors }; + } + + // Check file size + if (file.size > maxSize) { + errors.push(`File size exceeds maximum allowed size of ${maxSize / (1024 * 1024)} MB`); + } + + // Check MIME type + const detectedType = await fileTypeFromBuffer(file.buffer); + if (!detectedType || !allowedMimeTypes.includes(detectedType.mime)) { + errors.push(`File type ${detectedType?.mime || 'unknown'} is not allowed`); + } + + // Check file extension + const fileExtension = path.extname(file.originalname).toLowerCase(); + const validExtensions = getAllowedExtensionsForMimeTypes(allowedMimeTypes); + if (!validExtensions.some(ext => ext === fileExtension)) { + errors.push(`File extension ${fileExtension} is not allowed`); + } + + // Additional security checks for potential malicious content + if (await containsMaliciousContent(file.buffer)) { + errors.push('File contains potentially malicious content'); + } + + return { isValid: errors.length === 0, errors }; +} + +/** + * Helper function to get allowed extensions for given mime types + */ +function getAllowedExtensionsForMimeTypes(allowedMimeTypes: string[]): string[] { + const extensionsMap: { [key: string]: string[] } = { + 'image/jpeg': ['.jpg', '.jpeg'], + 'image/png': ['.png'], + 'image/webp': ['.webp'], + 'image/gif': ['.gif'], + 'image/svg+xml': ['.svg'], + 'image/tiff': ['.tiff', '.tif'], + 'image/bmp': ['.bmp'], + 'image/x-icon': ['.ico'], + 'application/pdf': ['.pdf'], + 'application/msword': ['.doc'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], + 'application/vnd.ms-excel': ['.xls'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], + 'application/vnd.ms-powerpoint': ['.ppt'], + 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'], + 'text/plain': ['.txt'], + 'text/csv': ['.csv'], + 'application/json': ['.json'], + 'application/xml': ['.xml'], + 'application/zip': ['.zip'], + 'application/x-rar-compressed': ['.rar'], + 'application/x-tar': ['.tar'], + 'application/gzip': ['.gz'], + 'application/x-7z-compressed': ['.7z'], + }; + + const extensions: string[] = []; + allowedMimeTypes.forEach(mimeType => { + if (extensionsMap[mimeType]) { + extensions.push(...extensionsMap[mimeType]); + } + }); + + return extensions; +} + +/** + * Helper function to check for malicious content in buffer + */ +async function containsMaliciousContent(buffer: Buffer): Promise { + // Check for common malicious patterns in the file content + const maliciousPatterns = [ + /)<[^<]*)*<\/script>/gi, // JavaScript in HTML + /)<[^<]*)*<\/iframe>/gi, // iframe in HTML + /)<[^<]*)*<\/object>/gi, // object in HTML + /)<[^<]*)*<\/embed>/gi, // embed in HTML + /]+rel=["']stylesheet["'][^>]*href=["']javascript:/gi, // JS in link href + /]+http-equiv=["']refresh["'][^>]*content=["']\d+;url=javascript:/gi, // JS in meta refresh + /vbscript:/gi, // VBScript protocol + /javascript:/gi, // JavaScript protocol + /data:text\/html/gi, // Data URI with HTML + /eval\s*\(/gi, // eval function + /expression\s*\(/gi, // expression function (IE) + /onload\s*=/gi, // onload event + /onerror\s*=/gi, // onerror event + /onclick\s*=/gi, // onclick event + /onmouseover\s*=/gi, // onmouseover event + /onfocus\s*=/gi, // onfocus event + /onblur\s*=/gi, // onblur event + ]; + + const content = buffer.toString('utf-8').toLowerCase(); + + for (const pattern of maliciousPatterns) { + if (pattern.test(content)) { + return true; + } + } + + return false; +} + +/** + * Get file type information + */ +export async function getFileTypeInfo(buffer: Buffer): Promise<{ mimeType: string; extension: string; isValid: boolean }> { + const detectedType = await fileTypeFromBuffer(buffer); + + if (!detectedType) { + return { mimeType: 'unknown', extension: '', isValid: false }; + } + + return { + mimeType: detectedType.mime, + extension: `.${detectedType.ext}`, + isValid: ALLOWED_MIME_TYPES.includes(detectedType.mime) + }; +} \ No newline at end of file diff --git a/src/common/validators/sql-injection.validator.ts b/src/common/validators/sql-injection.validator.ts new file mode 100644 index 00000000..e6fc1f8d --- /dev/null +++ b/src/common/validators/sql-injection.validator.ts @@ -0,0 +1,115 @@ +import { registerDecorator, ValidationOptions, ValidatorConstraint, ValidatorConstraintInterface } from 'class-validator'; + +// Common SQL injection patterns +const SQL_INJECTION_PATTERNS = [ + /(\b(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|EXEC|UNION|SCRIPT)\b)/gi, + /(;|\-\-|\#|\/\*|\*\/)/g, + /(\b(OR|AND)\b\s*\d+\s*[=<>]\s*\d+)/gi, + /(=\s*'?\d+'\s*(OR|AND))/gi, + /('|--|#|\/\*|\*\/)/g, + /\b(OR|AND)\b\s+1\s*=\s*1/gi, + /\b(OR|AND)\b\s+'1'\s*=\s*'1'/gi, +]; + +/** + * Custom validator constraint for SQL injection prevention + */ +@ValidatorConstraint({ async: false }) +export class SqlInjectionValidatorConstraint implements ValidatorConstraintInterface { + validate(value: any) { + if (typeof value !== 'string') { + return true; // Only validate strings + } + + // Check against known SQL injection patterns + for (const pattern of SQL_INJECTION_PATTERNS) { + if (pattern.test(value)) { + return false; + } + } + + return true; + } + + defaultMessage() { + return 'The input contains potentially malicious SQL injection content'; + } +} + +/** + * Decorator to validate input against SQL injection + */ +export function IsNotSqlInjection(validationOptions?: ValidationOptions) { + return function (object: Object, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + constraints: [], + validator: SqlInjectionValidatorConstraint, + }); + }; +} + +/** + * Function to check if a string contains potential SQL injection patterns + */ +export function containsSqlInjection(input: string): boolean { + if (typeof input !== 'string') { + return false; + } + + for (const pattern of SQL_INJECTION_PATTERNS) { + if (pattern.test(input)) { + return true; + } + } + + return false; +} + +/** + * Function to sanitize input against SQL injection + */ +export function sanitizeSqlInjection(input: string): string { + if (typeof input !== 'string') { + return input; + } + + let sanitized = input; + + // Remove potentially dangerous SQL keywords and characters + sanitized = sanitized.replace(/(\b(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|EXEC|UNION|SCRIPT)\b)/gi, ''); + sanitized = sanitized.replace(/(;|\-\-|\#|\/\*|\*\/)/g, ''); + sanitized = sanitized.replace(/(\b(OR|AND)\b\s*\d+\s*[=<>]\s*\d+)/gi, ''); + sanitized = sanitized.replace(/('|--|#|\/\*|\*\/)/g, ''); + + return sanitized.trim(); +} + +/** + * Function to sanitize an object against SQL injection + */ +export function sanitizeObjectSqlInjection(obj: any): any { + if (obj === null || obj === undefined) { + return obj; + } + + if (typeof obj === 'string') { + return sanitizeSqlInjection(obj); + } + + if (typeof obj === 'object') { + const sanitized: any = Array.isArray(obj) ? [] : {}; + + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + sanitized[key] = sanitizeObjectSqlInjection(obj[key]); + } + } + + return sanitized; + } + + return obj; +} \ No newline at end of file diff --git a/src/common/validators/xss.validator.ts b/src/common/validators/xss.validator.ts new file mode 100644 index 00000000..d4f07606 --- /dev/null +++ b/src/common/validators/xss.validator.ts @@ -0,0 +1,75 @@ +import { registerDecorator, ValidationOptions, ValidatorConstraint, ValidatorConstraintInterface } from 'class-validator'; +import xss from 'xss'; + +/** + * Custom validator constraint for XSS protection + */ +@ValidatorConstraint({ async: false }) +export class XssValidatorConstraint implements ValidatorConstraintInterface { + validate(value: any) { + if (typeof value !== 'string') { + return true; // Only validate strings + } + + // Check if sanitized value differs from original (indicating potential XSS) + const sanitized = xss(value); + return sanitized === value; + } + + defaultMessage() { + return 'The input contains potentially malicious content (XSS)'; + } +} + +/** + * Decorator to validate and sanitize input against XSS attacks + */ +export function IsXssSafe(validationOptions?: ValidationOptions) { + return function (object: Object, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + constraints: [], + validator: XssValidatorConstraint, + }); + }; +} + +/** + * Function to sanitize input against XSS + */ +export function sanitizeXss(input: string): string { + if (typeof input !== 'string') { + return input; + } + + return xss(input); +} + +/** + * Function to sanitize an object against XSS + */ +export function sanitizeObjectXss(obj: any): any { + if (obj === null || obj === undefined) { + return obj; + } + + if (typeof obj === 'string') { + return sanitizeXss(obj); + } + + if (typeof obj === 'object') { + const sanitized: any = Array.isArray(obj) ? [] : {}; + + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + sanitized[key] = sanitizeObjectXss(obj[key]); + } + } + + return sanitized; + } + + return obj; +} \ No newline at end of file diff --git a/src/properties/dto/create-property.dto.ts b/src/properties/dto/create-property.dto.ts index 3f0c63d9..9c1e1a8d 100644 --- a/src/properties/dto/create-property.dto.ts +++ b/src/properties/dto/create-property.dto.ts @@ -14,6 +14,8 @@ import { } from 'class-validator'; import { Type } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsXssSafe } from '../../common/validators/xss.validator'; +import { IsNotSqlInjection } from '../../common/validators/sql-injection.validator'; export enum PropertyType { RESIDENTIAL = 'RESIDENTIAL', @@ -38,6 +40,8 @@ export class AddressDto { @IsString({ message: 'Street must be a string' }) @IsNotEmpty({ message: 'Street is required' }) @MaxLength(255, { message: 'Street must not exceed 255 characters' }) + @IsXssSafe({ message: 'Street address contains potentially malicious content' }) + @IsNotSqlInjection({ message: 'Street address contains potential SQL injection' }) street: string; @ApiProperty({ @@ -48,6 +52,8 @@ export class AddressDto { @IsString({ message: 'City must be a string' }) @IsNotEmpty({ message: 'City is required' }) @MaxLength(100, { message: 'City must not exceed 100 characters' }) + @IsXssSafe({ message: 'City name contains potentially malicious content' }) + @IsNotSqlInjection({ message: 'City name contains potential SQL injection' }) city: string; @ApiPropertyOptional({ @@ -58,6 +64,8 @@ export class AddressDto { @IsOptional() @IsString({ message: 'State must be a string' }) @MaxLength(100, { message: 'State must not exceed 100 characters' }) + @IsXssSafe({ message: 'State/province contains potentially malicious content' }) + @IsNotSqlInjection({ message: 'State/province contains potential SQL injection' }) state?: string; @ApiPropertyOptional({ @@ -68,6 +76,8 @@ export class AddressDto { @IsOptional() @IsString({ message: 'Postal code must be a string' }) @MaxLength(20, { message: 'Postal code must not exceed 20 characters' }) + @IsXssSafe({ message: 'Postal code contains potentially malicious content' }) + @IsNotSqlInjection({ message: 'Postal code contains potential SQL injection' }) postalCode?: string; @ApiProperty({ @@ -78,6 +88,8 @@ export class AddressDto { @IsString({ message: 'Country must be a string' }) @IsNotEmpty({ message: 'Country is required' }) @MaxLength(100, { message: 'Country must not exceed 100 characters' }) + @IsXssSafe({ message: 'Country name contains potentially malicious content' }) + @IsNotSqlInjection({ message: 'Country name contains potential SQL injection' }) country: string; } @@ -90,6 +102,8 @@ export class CreatePropertyDto { @IsString({ message: 'Title must be a string' }) @IsNotEmpty({ message: 'Title is required' }) @MaxLength(200, { message: 'Title must not exceed 200 characters' }) + @IsXssSafe({ message: 'Property title contains potentially malicious content' }) + @IsNotSqlInjection({ message: 'Property title contains potential SQL injection' }) title: string; @ApiPropertyOptional({ @@ -100,6 +114,8 @@ export class CreatePropertyDto { @IsOptional() @IsString({ message: 'Description must be a string' }) @MaxLength(5000, { message: 'Description must not exceed 5000 characters' }) + @IsXssSafe({ message: 'Property description contains potentially malicious content' }) + @IsNotSqlInjection({ message: 'Property description contains potential SQL injection' }) description?: string; @ApiProperty({ @@ -132,6 +148,8 @@ export class CreatePropertyDto { @IsString({ each: true, message: 'Each feature must be a string' }) @ArrayMaxSize(50, { message: 'Cannot have more than 50 features' }) @MaxLength(100, { each: true, message: 'Each feature must not exceed 100 characters' }) + @IsXssSafe({ each: true, message: 'Feature contains potentially malicious content' }) + @IsNotSqlInjection({ each: true, message: 'Feature contains potential SQL injection' }) features?: string[]; @ApiPropertyOptional({ diff --git a/src/users/dto/create-user.dto.ts b/src/users/dto/create-user.dto.ts index c8752e0e..0c4a6158 100644 --- a/src/users/dto/create-user.dto.ts +++ b/src/users/dto/create-user.dto.ts @@ -2,6 +2,8 @@ import { IsEmail, IsString, IsOptional, MinLength, IsNotEmpty, MaxLength, Matche import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsEthereumAddress } from '../../common/validators/is-ethereum-address.validator'; import { IsStrongPassword } from '../../common/validators/is-strong-password.validator'; +import { IsXssSafe } from '../../common/validators/xss.validator'; +import { IsNotSqlInjection } from '../../common/validators/sql-injection.validator'; export class CreateUserDto { @ApiProperty({ @@ -12,6 +14,8 @@ export class CreateUserDto { @IsEmail({}, { message: 'Please provide a valid email address' }) @IsNotEmpty({ message: 'Email is required' }) @MaxLength(255, { message: 'Email must not exceed 255 characters' }) + @IsXssSafe({ message: 'Email contains potentially malicious content' }) + @IsNotSqlInjection({ message: 'Email contains potential SQL injection' }) email: string; @ApiProperty({ @@ -27,6 +31,8 @@ export class CreateUserDto { @Matches(/^[a-zA-Z\s'-]+$/, { message: 'First name can only contain letters, spaces, hyphens, and apostrophes', }) + @IsXssSafe({ message: 'First name contains potentially malicious content' }) + @IsNotSqlInjection({ message: 'First name contains potential SQL injection' }) firstName: string; @ApiProperty({ @@ -42,6 +48,8 @@ export class CreateUserDto { @Matches(/^[a-zA-Z\s'-]+$/, { message: 'Last name can only contain letters, spaces, hyphens, and apostrophes', }) + @IsXssSafe({ message: 'Last name contains potentially malicious content' }) + @IsNotSqlInjection({ message: 'Last name contains potential SQL injection' }) lastName: string; @ApiProperty({ @@ -54,6 +62,8 @@ export class CreateUserDto { @IsNotEmpty({ message: 'Password is required' }) @MinLength(8, { message: 'Password must be at least 8 characters' }) @MaxLength(128, { message: 'Password must not exceed 128 characters' }) + @IsXssSafe({ message: 'Password contains potentially malicious content' }) + @IsNotSqlInjection({ message: 'Password contains potential SQL injection' }) @IsStrongPassword() password: string; @@ -63,5 +73,7 @@ export class CreateUserDto { }) @IsOptional() @IsEthereumAddress({ message: 'Invalid Ethereum wallet address format' }) + @IsXssSafe({ message: 'Wallet address contains potentially malicious content' }) + @IsNotSqlInjection({ message: 'Wallet address contains potential SQL injection' }) walletAddress?: string; } From a3e07fbcb9d0642b06e07c84447bccc1135c0692 Mon Sep 17 00:00:00 2001 From: kamaldeen Aliyu Date: Fri, 20 Feb 2026 12:11:23 +0100 Subject: [PATCH 2/4] Adjustments --- src/common/controllers/audit.controller.ts | 1 - .../request-size-limiter.middleware.ts | 18 ++++--------- .../validators/file-upload.validator.ts | 25 ++++++++++++------- .../validators/sql-injection.validator.ts | 13 +++++++--- src/common/validators/xss.validator.ts | 17 ++++++++----- 5 files changed, 41 insertions(+), 33 deletions(-) diff --git a/src/common/controllers/audit.controller.ts b/src/common/controllers/audit.controller.ts index 118451b8..207cf96f 100644 --- a/src/common/controllers/audit.controller.ts +++ b/src/common/controllers/audit.controller.ts @@ -27,7 +27,6 @@ import { Action } from '../../rbac/enums/action.enum'; import { RequireScopes } from '../decorators/require-scopes.decorator'; import { AuditInterceptor } from '../interceptors/audit.interceptor'; - @ApiTags('Audit & Compliance') @Controller('audit') @UseGuards(JwtAuthGuard, RbacGuard) diff --git a/src/common/middleware/request-size-limiter.middleware.ts b/src/common/middleware/request-size-limiter.middleware.ts index 0a80739f..fb80cdb4 100644 --- a/src/common/middleware/request-size-limiter.middleware.ts +++ b/src/common/middleware/request-size-limiter.middleware.ts @@ -17,17 +17,13 @@ export class RequestSizeLimiterMiddleware implements NestMiddleware { use(req: Request, res: Response, next: NextFunction) { // Check URL length if (req.url.length > this.maxUrlLength) { - throw new BadRequestException( - `Request URL exceeds maximum allowed length of ${this.maxUrlLength} characters` - ); + throw new BadRequestException(`Request URL exceeds maximum allowed length of ${this.maxUrlLength} characters`); } // Check number of headers const headerCount = Object.keys(req.headers).length; if (headerCount > this.maxHeadersCount) { - throw new BadRequestException( - `Request exceeds maximum allowed headers count of ${this.maxHeadersCount}` - ); + throw new BadRequestException(`Request exceeds maximum allowed headers count of ${this.maxHeadersCount}`); } // Check content length header if present @@ -35,9 +31,7 @@ export class RequestSizeLimiterMiddleware implements NestMiddleware { if (contentLength) { const size = parseInt(contentLength, 10); if (size > this.maxRequestSize) { - throw new BadRequestException( - `Request body exceeds maximum allowed size of ${this.maxRequestSize} bytes` - ); + throw new BadRequestException(`Request body exceeds maximum allowed size of ${this.maxRequestSize} bytes`); } } @@ -48,13 +42,11 @@ export class RequestSizeLimiterMiddleware implements NestMiddleware { receivedSize += chunk.length; if (receivedSize > this.maxRequestSize) { req.destroy(); - throw new BadRequestException( - `Request body exceeds maximum allowed size of ${this.maxRequestSize} bytes` - ); + throw new BadRequestException(`Request body exceeds maximum allowed size of ${this.maxRequestSize} bytes`); } }); } next(); } -} \ No newline at end of file +} diff --git a/src/common/validators/file-upload.validator.ts b/src/common/validators/file-upload.validator.ts index 084be937..cc03ac6b 100644 --- a/src/common/validators/file-upload.validator.ts +++ b/src/common/validators/file-upload.validator.ts @@ -1,4 +1,9 @@ -import { registerDecorator, ValidationOptions, ValidatorConstraint, ValidatorConstraintInterface } from 'class-validator'; +import { + registerDecorator, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; import { HttpStatus } from '@nestjs/common'; import * as fs from 'fs'; import * as path from 'path'; @@ -15,7 +20,7 @@ const ALLOWED_MIME_TYPES = [ 'image/tiff', 'image/bmp', 'image/x-icon', - + // Documents 'application/pdf', 'application/msword', @@ -153,10 +158,10 @@ export class FileUploadValidatorConstraint implements ValidatorConstraintInterfa * Decorator to validate uploaded files */ export function IsValidFileUpload(validationOptions?: ValidationOptions) { - return function (object: Object, propertyName: string) { + return function (object: Record, propertyName: string) { registerDecorator({ target: object.constructor, - propertyName: propertyName, + propertyName, options: validationOptions, constraints: [], validator: FileUploadValidatorConstraint, @@ -170,7 +175,7 @@ export function IsValidFileUpload(validationOptions?: ValidationOptions) { export async function validateFileUpload( file: Express.Multer.File, allowedMimeTypes: string[] = ALLOWED_MIME_TYPES, - maxSize: number = MAX_FILE_SIZE + maxSize: number = MAX_FILE_SIZE, ): Promise<{ isValid: boolean; errors: string[] }> { const errors: string[] = []; @@ -285,9 +290,11 @@ async function containsMaliciousContent(buffer: Buffer): Promise { /** * Get file type information */ -export async function getFileTypeInfo(buffer: Buffer): Promise<{ mimeType: string; extension: string; isValid: boolean }> { +export async function getFileTypeInfo( + buffer: Buffer, +): Promise<{ mimeType: string; extension: string; isValid: boolean }> { const detectedType = await fileTypeFromBuffer(buffer); - + if (!detectedType) { return { mimeType: 'unknown', extension: '', isValid: false }; } @@ -295,6 +302,6 @@ export async function getFileTypeInfo(buffer: Buffer): Promise<{ mimeType: strin return { mimeType: detectedType.mime, extension: `.${detectedType.ext}`, - isValid: ALLOWED_MIME_TYPES.includes(detectedType.mime) + isValid: ALLOWED_MIME_TYPES.includes(detectedType.mime), }; -} \ No newline at end of file +} diff --git a/src/common/validators/sql-injection.validator.ts b/src/common/validators/sql-injection.validator.ts index e6fc1f8d..bbe74fdf 100644 --- a/src/common/validators/sql-injection.validator.ts +++ b/src/common/validators/sql-injection.validator.ts @@ -1,4 +1,9 @@ -import { registerDecorator, ValidationOptions, ValidatorConstraint, ValidatorConstraintInterface } from 'class-validator'; +import { + registerDecorator, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; // Common SQL injection patterns const SQL_INJECTION_PATTERNS = [ @@ -40,10 +45,10 @@ export class SqlInjectionValidatorConstraint implements ValidatorConstraintInter * Decorator to validate input against SQL injection */ export function IsNotSqlInjection(validationOptions?: ValidationOptions) { - return function (object: Object, propertyName: string) { + return function (object: Record, propertyName: string) { registerDecorator({ target: object.constructor, - propertyName: propertyName, + propertyName, options: validationOptions, constraints: [], validator: SqlInjectionValidatorConstraint, @@ -112,4 +117,4 @@ export function sanitizeObjectSqlInjection(obj: any): any { } return obj; -} \ No newline at end of file +} diff --git a/src/common/validators/xss.validator.ts b/src/common/validators/xss.validator.ts index d4f07606..f7bc29a8 100644 --- a/src/common/validators/xss.validator.ts +++ b/src/common/validators/xss.validator.ts @@ -1,4 +1,9 @@ -import { registerDecorator, ValidationOptions, ValidatorConstraint, ValidatorConstraintInterface } from 'class-validator'; +import { + registerDecorator, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; import xss from 'xss'; /** @@ -10,7 +15,7 @@ export class XssValidatorConstraint implements ValidatorConstraintInterface { if (typeof value !== 'string') { return true; // Only validate strings } - + // Check if sanitized value differs from original (indicating potential XSS) const sanitized = xss(value); return sanitized === value; @@ -25,10 +30,10 @@ export class XssValidatorConstraint implements ValidatorConstraintInterface { * Decorator to validate and sanitize input against XSS attacks */ export function IsXssSafe(validationOptions?: ValidationOptions) { - return function (object: Object, propertyName: string) { + return function (object: Record, propertyName: string) { registerDecorator({ target: object.constructor, - propertyName: propertyName, + propertyName, options: validationOptions, constraints: [], validator: XssValidatorConstraint, @@ -43,7 +48,7 @@ export function sanitizeXss(input: string): string { if (typeof input !== 'string') { return input; } - + return xss(input); } @@ -72,4 +77,4 @@ export function sanitizeObjectXss(obj: any): any { } return obj; -} \ No newline at end of file +} From 041492ba36a4dc3ddaca13a92f41dfd37a419c5a Mon Sep 17 00:00:00 2001 From: kamaldeen Aliyu Date: Fri, 20 Feb 2026 12:35:10 +0100 Subject: [PATCH 3/4] fix errors --- src/common/validators/xss.validator.ts | 40 ++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/src/common/validators/xss.validator.ts b/src/common/validators/xss.validator.ts index f7bc29a8..97a11f47 100644 --- a/src/common/validators/xss.validator.ts +++ b/src/common/validators/xss.validator.ts @@ -4,7 +4,7 @@ import { ValidatorConstraint, ValidatorConstraintInterface, } from 'class-validator'; -import xss from 'xss'; +import * as xss from 'xss'; /** * Custom validator constraint for XSS protection @@ -15,10 +15,28 @@ export class XssValidatorConstraint implements ValidatorConstraintInterface { if (typeof value !== 'string') { return true; // Only validate strings } - - // Check if sanitized value differs from original (indicating potential XSS) - const sanitized = xss(value); - return sanitized === value; + + // Check if value contains potential XSS patterns + // Instead of sanitizing, we'll check for dangerous patterns + const dangerousPatterns = [ + /]/i, // Script tags + /]/i, // Iframe tags + /]/i, // Object tags + /]/i, // Embed tags + /].*?javascript:/i, // Form with JS + /javascript:/i, // JavaScript protocol + /vbscript:/i, // VBScript protocol + /data:text\/html/i, // Data HTML URIs + /onload\s*=|onerror\s*=|onclick\s*=|onmouseover\s*=|onfocus\s*=|onblur\s*=/i, // Event handlers + ]; + + for (const pattern of dangerousPatterns) { + if (pattern.test(value)) { + return false; + } + } + + return true; } defaultMessage() { @@ -49,7 +67,17 @@ export function sanitizeXss(input: string): string { return input; } - return xss(input); + if (typeof input !== 'string') { + return input; + } + + // For sanitization, we'll use the xss library with a configuration + // that removes all HTML tags but preserves plain text + const options = { + whiteList: {}, // Allow no HTML tags for maximum security + }; + + return xss.filterXSS(input, options); } /** From d69ce11407ede1e58c4501a1fd7d438dca7112a1 Mon Sep 17 00:00:00 2001 From: kamaldeen Aliyu Date: Fri, 20 Feb 2026 13:42:34 +0100 Subject: [PATCH 4/4] fixed the build error --- src/common/validators/sql-injection.validator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common/validators/sql-injection.validator.ts b/src/common/validators/sql-injection.validator.ts index bbe74fdf..ac82e376 100644 --- a/src/common/validators/sql-injection.validator.ts +++ b/src/common/validators/sql-injection.validator.ts @@ -11,7 +11,7 @@ const SQL_INJECTION_PATTERNS = [ /(;|\-\-|\#|\/\*|\*\/)/g, /(\b(OR|AND)\b\s*\d+\s*[=<>]\s*\d+)/gi, /(=\s*'?\d+'\s*(OR|AND))/gi, - /('|--|#|\/\*|\*\/)/g, + /(--|#|\/\*|\*\/)/g, /\b(OR|AND)\b\s+1\s*=\s*1/gi, /\b(OR|AND)\b\s+'1'\s*=\s*'1'/gi, ];