diff --git a/src/middlewares/malwareScan.middleware.ts b/src/middlewares/malwareScan.middleware.ts index 6925bf3..1a0448c 100644 --- a/src/middlewares/malwareScan.middleware.ts +++ b/src/middlewares/malwareScan.middleware.ts @@ -66,7 +66,7 @@ function deleteTempFile(tempPath: string): void { * Malware scan middleware for single file uploads (multer.single) * Works with memory storage — writes buffer to temp → scans → deletes temp */ -export function malwareScanMiddleware(req: Request, res: Response, next: NextFunction): void { +export async function malwareScanMiddleware(req: Request, res: Response, next: NextFunction): Promise { // Skip if no file uploaded const file = req.file; if (!file) { @@ -79,14 +79,14 @@ export function malwareScanMiddleware(req: Request, res: Response, next: NextFun req.scanEventId = scanEventId; // Handle the async scan - performScan(file, scanEventId, req, res, next); + await performScan(file, scanEventId, req, res, next); } /** * Malware scan middleware for multiple file uploads (multer.array / multer.fields) * Scans all files and blocks if ANY file is infected */ -export function malwareScanMultipleMiddleware(req: Request, res: Response, next: NextFunction): void { +export async function malwareScanMultipleMiddleware(req: Request, res: Response, next: NextFunction): Promise { // Handle multer.array() const files = req.files; if (!files || (Array.isArray(files) && files.length === 0)) { @@ -100,7 +100,7 @@ export function malwareScanMultipleMiddleware(req: Request, res: Response, next: // Handle array of files if (Array.isArray(files)) { - performMultiScan(files, scanEventId, req, res, next); + await performMultiScan(files, scanEventId, req, res, next); return; } @@ -115,7 +115,7 @@ export function malwareScanMultipleMiddleware(req: Request, res: Response, next: return next(); } - performMultiScan(allFiles, scanEventId, req, res, next); + await performMultiScan(allFiles, scanEventId, req, res, next); } // ── Core scan logic ── @@ -132,7 +132,7 @@ async function performScan( try { // Step 0: Pre-scan file validation (extension, MIME, magic bytes, blocked patterns) const maxSizeMB = parseInt(process.env.MAX_FILE_SIZE_MB || '50', 10); - const validation = validateFile( + const validation = await validateFile( file.originalname, file.mimetype, file.buffer || null, @@ -304,7 +304,7 @@ async function performMultiScan( // Step 0: Pre-scan file validation const maxSizeMB = parseInt(process.env.MAX_FILE_SIZE_MB || '50', 10); - const validation = validateFile( + const validation = await validateFile( file.originalname, file.mimetype, file.buffer || null, diff --git a/src/routes/dealerClaim.routes.ts b/src/routes/dealerClaim.routes.ts index 15a3d94..bdb304c 100644 --- a/src/routes/dealerClaim.routes.ts +++ b/src/routes/dealerClaim.routes.ts @@ -63,7 +63,7 @@ router.get('/:requestId', authenticateToken, validateParams(requestIdParamsSchem * @desc Submit dealer proposal (Step 1) * @access Private */ -router.post('/:requestId/proposal', authenticateToken, validateParams(requestIdParamsSchema), upload.single('proposalDocument'), malwareScanMiddleware, asyncHandler(dealerClaimController.submitProposal.bind(dealerClaimController))); +router.post('/:requestId/proposal', authenticateToken, validateParams(requestIdParamsSchema), upload.single('proposalDocument'), asyncHandler(malwareScanMiddleware), asyncHandler(dealerClaimController.submitProposal.bind(dealerClaimController))); /** * @route POST /api/v1/dealer-claims/:requestId/completion @@ -75,7 +75,7 @@ router.post('/:requestId/completion', authenticateToken, uploadLimiter, validate { name: 'activityPhotos', maxCount: 10 }, { name: 'invoicesReceipts', maxCount: 10 }, { name: 'attendanceSheet', maxCount: 1 }, -]), malwareScanMultipleMiddleware, asyncHandler(dealerClaimController.submitCompletion.bind(dealerClaimController))); +]), asyncHandler(malwareScanMultipleMiddleware), asyncHandler(dealerClaimController.submitCompletion.bind(dealerClaimController))); /** * @route GET /api/v1/dealer-claims/:requestId/io/validate @@ -105,7 +105,7 @@ router.get('/:requestId/e-invoice/pdf', authenticateToken, validateParams(reques * @access Private */ router.get('/:requestId/e-invoice/csv', authenticateToken, validateParams(requestIdParamsSchema), asyncHandler(dealerClaimController.downloadInvoiceCsv.bind(dealerClaimController))); -router.post('/:requestId/credit-note', authenticateToken, validateParams(requestIdParamsSchema), upload.single('creditNoteFile'), malwareScanMiddleware, asyncHandler(dealerClaimController.updateCreditNote.bind(dealerClaimController))); +router.post('/:requestId/credit-note', authenticateToken, validateParams(requestIdParamsSchema), upload.single('creditNoteFile'), asyncHandler(malwareScanMiddleware), asyncHandler(dealerClaimController.updateCreditNote.bind(dealerClaimController))); /** * @route POST /api/v1/dealer-claims/:requestId/credit-note/send diff --git a/src/routes/document.routes.ts b/src/routes/document.routes.ts index 4bcfe90..f13b1a1 100644 --- a/src/routes/document.routes.ts +++ b/src/routes/document.routes.ts @@ -22,6 +22,6 @@ const controller = new DocumentController(); // multipart/form-data: file, requestId, optional category // Middleware chain: auth → multer → malware scan → controller -router.post('/', authenticateToken, upload.single('file'), malwareScanMiddleware, asyncHandler(controller.upload.bind(controller))); +router.post('/', authenticateToken, upload.single('file'), asyncHandler(malwareScanMiddleware), asyncHandler(controller.upload.bind(controller))); export default router; diff --git a/src/services/fileUpload/fileValidationService.ts b/src/services/fileUpload/fileValidationService.ts index 7437105..68fe429 100644 --- a/src/services/fileUpload/fileValidationService.ts +++ b/src/services/fileUpload/fileValidationService.ts @@ -7,8 +7,11 @@ * - Path traversal blocking (e.g., ../../etc/passwd) * - Magic bytes / file signature validation * - Filename sanitization + * - Configuration-driven allowed file types */ +import { getConfigValue } from '../configReader.service'; + // ── Types ── export interface FileValidationResult { @@ -123,19 +126,29 @@ const BLOCKED_PATTERNS: Array<{ pattern: RegExp; reason: string }> = [ * Validate an uploaded file for security concerns. * This runs BEFORE ClamAV and catches things ClamAV won't flag. */ -export function validateFile( +export async function validateFile( originalName: string, mimeType: string, fileBuffer: Buffer | null, fileSizeBytes: number, maxSizeMB: number = 50, -): FileValidationResult { +): Promise { const errors: string[] = []; const warnings: string[] = []; // 1. Extract and validate extension const ext = originalName.split('.').pop()?.toLowerCase() || ''; - const allowedExtensions = Object.keys(EXTENSION_MIME_MAP); + + // Get allowed extensions from config, fallback to default EXTENSION_MIME_MAP keys + const allowedTypesConfig = await getConfigValue('ALLOWED_FILE_TYPES', ''); + let allowedExtensions: string[]; + + if (allowedTypesConfig) { + // e.g., "pdf, docx, jpg" -> ["pdf", "docx", "jpg"] + allowedExtensions = allowedTypesConfig.split(',').map(e => e.trim().toLowerCase()).filter(e => e); + } else { + allowedExtensions = Object.keys(EXTENSION_MIME_MAP); + } if (!ext) { errors.push('File has no extension'); @@ -160,13 +173,14 @@ export function validateFile( errors.push('File is empty (0 bytes)'); } - // 4. MIME type ↔ extension mismatch detection (warning only — browsers/multer can report wrong MIME) + // 4. MIME type ↔ extension mismatch detection let mimeMatchesExtension = true; if (ext && EXTENSION_MIME_MAP[ext]) { const allowedMimes = EXTENSION_MIME_MAP[ext]; if (!allowedMimes.includes(mimeType) && mimeType !== 'application/octet-stream') { mimeMatchesExtension = false; - warnings.push( + // Block if the uploaded file's claimed mimetype doesn't match its extension + errors.push( `MIME type mismatch: file claims ".${ext}" but has MIME "${mimeType}". ` + `Expected: ${allowedMimes.join(' or ')}` ); @@ -186,15 +200,26 @@ export function validateFile( // Check if magic bytes match claimed extension if (ext) { const expectedSignatures = MAGIC_BYTES.filter(m => m.ext === ext); + // If we know the expected signatures for this extension, enforce them if (expectedSignatures.length > 0) { const matchesAny = expectedSignatures.some(sig => matchesBytes(fileBuffer, sig.bytes, sig.offset)); if (!matchesAny) { - // Warning only — some legitimate files have variant headers - // ClamAV will do the real malware check - warnings.push( - `File header does not match ".${ext}" signature — file may be corrupted or mislabeled` + // Block uploads where the file's actual magic bytes do not match the expected ones + errors.push( + `File header does not match ".${ext}" signature — file may be a different type disguised as ".${ext}"` ); } + } else { + // If we DON'T strictly know the signatures for this extension, + // we should check if its bytes match a KNOWN DIFFERENT file type + for (const { ext: knownExt, bytes: knownBytes, offset: knownOffset } of MAGIC_BYTES) { + if (knownExt !== ext && matchesBytes(fileBuffer, knownBytes, knownOffset)) { + errors.push( + `File signature mismatch: claims to be ".${ext}" but header matches ".${knownExt}"` + ); + break; + } + } } }